diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 58ef0c12df3..691e28756ce 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -105,6 +105,7 @@ COPY agents/hermes/patch-cron-execution-runtime.py /opt/nemoclaw-hermes-config/p COPY agents/hermes/patch-cron-restore-drain.py /opt/nemoclaw-hermes-config/patch-cron-restore-drain.py COPY agents/hermes/patch-neutral-platform-env-activation.py /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json +COPY src/lib/hermes-managed-route.ts /src/lib/hermes-managed-route.ts COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY src/lib/messaging/ /src/lib/messaging/ COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts @@ -128,6 +129,7 @@ COPY agents/hermes/validate-env-secret-boundary.py /usr/local/lib/nemoclaw/valid COPY agents/hermes/patch-session-list-preview.py /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py COPY agents/hermes/patch-discord-recovery-permissions.py /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py COPY agents/hermes/patch-profile-policy-defaults.py /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py +COPY agents/hermes/managed_policy.py /usr/local/lib/nemoclaw/managed_policy.py COPY agents/hermes/patch-langfuse-credentials.mts /usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts COPY agents/hermes/seed-dashboard-config.py /usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py COPY agents/hermes/runtime-config-guard.py /usr/local/lib/nemoclaw/hermes-runtime-config-guard.py @@ -318,13 +320,13 @@ RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/ # read-only. RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ && find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} + \ - && chmod 444 /src/lib/tool-disclosure.ts \ + && chmod 444 /src/lib/hermes-managed-route.ts /src/lib/tool-disclosure.ts \ && chmod 444 /scripts/lib/reviewed-npm-archive.mts /scripts/lib/openclaw-npm-remediation.mts \ /scripts/patch-bundled-npm-brace-expansion.mts /scripts/lib/patch-bundled-npm-ip-address.mts \ /scripts/patch-bundled-npm-tar.mts \ && chmod -R a+rX /src/lib/messaging -ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=284e2bf46c82f25967b86f73fbefcd97315a1284a4b079bd6f13e6153bece4b6 +ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=61fec509d9c0229650f71d1fc53d103a39fd830976213dbdef0d222592981d03 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256" /opt/nemoclaw-hermes-config/image-build-probes.py \ @@ -363,7 +365,7 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-star && chown root:root /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/share/nemoclaw/state-lock-plan.json /usr/local/lib/nemoclaw/managed-gateway-control.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/hermes-cron-restore-control.py /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ && chmod 700 /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/hermes-cron-restore-control.py \ && chmod 500 /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/lib/nemoclaw/managed-gateway-control.py \ - && chmod 444 /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/share/nemoclaw/state-lock-plan.json /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py \ + && chmod 444 /usr/local/lib/nemoclaw/entrypoint-env-wrapper.sh /usr/local/share/nemoclaw/state-lock-plan.json /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/managed_policy.py \ && chmod 444 /usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts \ && chmod 444 /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then \ @@ -424,57 +426,6 @@ RUN /usr/bin/python3 -I \ "$NEMOCLAW_HERMES_GATEWAY_RUN_DRAIN_PATCHED_SHA256" /opt/hermes/gateway/run.py \ | sha256sum -c - -# Fresh named profiles do not receive config.yaml, so pin the reviewed -# NemoClaw policy at Hermes' source defaults as well as in generated default -# and dashboard homes. The patcher binds all seven input modules to the exact -# upstream v2026.7.20 source hashes before editing them. -ARG NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256=0a05a28e39194016edc3b41448e5ec40abf759c8a445ad2083eb6e6705766599 -# hadolint ignore=DL4006 -RUN printf '%s %s\n' \ - "$NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256" /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py \ - | sha256sum -c - \ - || { echo "ERROR: patch-hermes-profile-policy-defaults.py hash mismatch (update NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256)" >&2; exit 1; } -RUN /usr/bin/python3 -I /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py - -# Hermes v0.19.0 has several built-in environment override paths that can -# re-enable an explicitly disabled platform when ambient credentials exist. -# Apply this after the profile-policy patch above, then bind both its exact -# output module and this narrow patcher before preserving explicit disables -# across environment processing. -ARG NEMOCLAW_HERMES_NEUTRAL_PLATFORM_PATCHER_SHA256=29769d0ae10646dd0a66b32dbefd5f85540544172d57cac96bb4f0941e260948 -ARG NEMOCLAW_HERMES_POST_PROFILE_GATEWAY_CONFIG_SHA256=2084c652a07614761d85703787f8697fc29560fe447f23362aa0bda5179dffa7 -ARG NEMOCLAW_HERMES_NEUTRAL_PLATFORM_OUTPUT_SHA256=5a1375664d1451b2fe9c3f2325f673149a90b9035588dfd4eb6618f785ecd6a2 -# hadolint ignore=DL4006 -RUN printf '%s %s\n' \ - "$NEMOCLAW_HERMES_NEUTRAL_PLATFORM_PATCHER_SHA256" /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py \ - "$NEMOCLAW_HERMES_POST_PROFILE_GATEWAY_CONFIG_SHA256" /opt/hermes/gateway/config.py \ - | sha256sum -c - \ - || { echo "ERROR: Hermes neutral platform environment source identity mismatch" >&2; exit 1; } -# hadolint ignore=DL4006 -RUN /usr/bin/python3 -I \ - /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py \ - /opt/hermes/gateway/config.py \ - && printf '%s %s\n' \ - "$NEMOCLAW_HERMES_NEUTRAL_PLATFORM_OUTPUT_SHA256" /opt/hermes/gateway/config.py \ - | sha256sum -c - - -# Prove the source-level fallback on the real installed runtime through the -# user-facing profile creation path. The new home must remain config-less while -# all security, privacy, update, browser, and session defaults fail safe. -RUN set -eu; \ - profile_probe_root="$(mktemp -d)"; \ - trap 'rm -rf "$profile_probe_root"' EXIT; \ - profile_probe_default="$profile_probe_root/.hermes"; \ - HOME="$profile_probe_root" HERMES_HOME="$profile_probe_default" \ - /usr/local/bin/hermes profile create nemoclaw-policy-probe \ - --no-alias --no-skills; \ - profile_probe_home="$profile_probe_default/profiles/nemoclaw-policy-probe"; \ - test -d "$profile_probe_home"; \ - test ! -e "$profile_probe_home/config.yaml"; \ - HOME="$profile_probe_root" HERMES_HOME="$profile_probe_home" \ - /opt/hermes/.venv/bin/python -I \ - /opt/nemoclaw-hermes-config/image-build-probes.py profile-policy - # Hermes v0.19.0 writes gateway lifecycle metadata directly below HERMES_HOME. # Shields-up deliberately makes that config root root-owned and non-writable, # so managed stop/start recovery cannot remove the old PID file and exits with @@ -591,7 +542,7 @@ RUN node --experimental-strip-types \ # silent supply-chain tampering of the build context (an attacker rewriting a # file has to also rewrite the Dockerfile-committed hash, which reviewers gate). # Regenerate with `sha256sum agents/hermes/{hermes-wrapper.py,hermes-cli-adapter-v1.json,validate-cli-adapter.py,validate-env-secret-boundary.py,finalize-tirith-marker.py,cron-restore-control.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=a841a3673cd2543dd53559e513741dd66929361b8ca94b9196b72badf7827d3c +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=f4276e9833638b7a620176c88bd329d6b6d4948538a3227b727a1397146a0e0e ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1 ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 @@ -774,6 +725,50 @@ RUN HERMES_HOME=/sandbox/.hermes /usr/local/bin/hermes doctor --fix \ && node --experimental-strip-types /opt/nemoclaw-hermes-config/generate-config.ts \ && if [ "$NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION" = "1" ]; then \ /opt/hermes/.venv/bin/python -I -c 'import importlib.metadata as m, pathlib, yaml; config=yaml.safe_load(pathlib.Path("/sandbox/.hermes/config.yaml").read_text()); neutral={name: value for name, value in config["platforms"].items() if name != "api_server"}; assert len(neutral) == 30; assert all(value == {"enabled": False} for value in neutral.values()); assert m.version("microsoft-teams-apps") == "2.0.13.4"; assert m.version("aiohttp") == "3.14.3"'; \ + fi \ + && rm -rf /sandbox/.cache + +USER root + +# Install the generated policy manifest outside the mutable Hermes home. Python +# consumers read this versioned artifact instead of embedding managed defaults. +RUN install -o root -g root -m 0444 \ + /sandbox/.hermes/managed-policy.json \ + /usr/local/share/nemoclaw/hermes-managed-policy.json \ + && rm -f /sandbox/.hermes/managed-policy.json + +# Fresh named profiles do not receive config.yaml. Patch the pinned Hermes +# fallback readers from the generated manifest, then validate a real profile. +ARG NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256=7468555c7596b3b95732fb98aec6152537778d8519a4409c3da8aa6a76c9a3f7 +# hadolint ignore=DL4006 +RUN printf '%s %s\n' \ + "$NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256" /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py \ + | sha256sum -c - \ + || { echo "ERROR: patch-hermes-profile-policy-defaults.py hash mismatch (update NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256)" >&2; exit 1; } +RUN /usr/bin/python3 -I /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py +# Hermes v0.19.0 has several built-in environment override paths that can +# re-enable an explicitly disabled platform when ambient credentials exist. +# Apply this after the profile-policy patch above, then bind both its exact +# output module and this narrow patcher before preserving explicit disables +# across environment processing. +ARG NEMOCLAW_HERMES_NEUTRAL_PLATFORM_PATCHER_SHA256=29769d0ae10646dd0a66b32dbefd5f85540544172d57cac96bb4f0941e260948 +ARG NEMOCLAW_HERMES_POST_PROFILE_GATEWAY_CONFIG_SHA256=b50a8390311c828fa9e13084e9af0caadafe2380ae161ef36dd4bdf792b22ee6 +ARG NEMOCLAW_HERMES_NEUTRAL_PLATFORM_OUTPUT_SHA256=77ad342af30d59a5b863d9f5f817247d816fd582fb12d38e074243f88d85b9f4 +# hadolint ignore=DL4006 +RUN printf '%s %s\n' \ + "$NEMOCLAW_HERMES_NEUTRAL_PLATFORM_PATCHER_SHA256" /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py \ + "$NEMOCLAW_HERMES_POST_PROFILE_GATEWAY_CONFIG_SHA256" /opt/hermes/gateway/config.py \ + | sha256sum -c - \ + || { echo "ERROR: Hermes neutral platform environment source identity mismatch" >&2; exit 1; } +# hadolint ignore=DL4006 +RUN /usr/bin/python3 -I \ + /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py \ + /opt/hermes/gateway/config.py \ + && printf '%s %s\n' \ + "$NEMOCLAW_HERMES_NEUTRAL_PLATFORM_OUTPUT_SHA256" /opt/hermes/gateway/config.py \ + | sha256sum -c - + +RUN if [ "$NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION" = "1" ]; then \ GOOGLE_CHAT_PROJECT_ID=nemoclaw-hostile \ GOOGLE_CHAT_SUBSCRIPTION_NAME=projects/nemoclaw-hostile/subscriptions/nemoclaw-hostile \ GOOGLE_CHAT_SERVICE_ACCOUNT_JSON='{"type":"service_account","project_id":"nemoclaw-hostile"}' \ @@ -781,8 +776,23 @@ RUN HERMES_HOME=/sandbox/.hermes /usr/local/bin/hermes doctor --fix \ WHATSAPP_CLOUD_ACCESS_TOKEN=nemoclaw-hostile \ HERMES_HOME=/sandbox/.hermes /opt/hermes/.venv/bin/python -I \ /opt/nemoclaw-hermes-config/image-build-probes.py neutral-platform-inertness; \ - fi \ - && rm -rf /sandbox/.cache + fi + +RUN set -eu; \ + profile_probe_root="$(mktemp -d)"; \ + trap 'rm -rf "$profile_probe_root"' EXIT; \ + profile_probe_default="$profile_probe_root/.hermes"; \ + HOME="$profile_probe_root" HERMES_HOME="$profile_probe_default" \ + /usr/local/bin/hermes profile create nemoclaw-policy-probe \ + --no-alias --no-skills; \ + profile_probe_home="$profile_probe_default/profiles/nemoclaw-policy-probe"; \ + test -d "$profile_probe_home"; \ + test ! -e "$profile_probe_home/config.yaml"; \ + HOME="$profile_probe_root" HERMES_HOME="$profile_probe_home" \ + /opt/hermes/.venv/bin/python -I \ + /opt/nemoclaw-hermes-config/image-build-probes.py profile-policy + +USER sandbox # Prove that the installed dashboard seeder carries every reviewed policy value # from the generated default home into a fresh isolated dashboard home. This is @@ -792,6 +802,7 @@ RUN dashboard_probe=/tmp/nemoclaw-dashboard-policy-probe.yaml \ && rm -f "$dashboard_probe" "$dashboard_probe.nemoclaw.tmp" \ && /opt/hermes/.venv/bin/python -I \ /usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py \ + /usr/local/share/nemoclaw/hermes-managed-policy.json \ /sandbox/.hermes/config.yaml "$dashboard_probe" \ && /opt/hermes/.venv/bin/python -I \ /opt/nemoclaw-hermes-config/image-build-probes.py \ @@ -1135,6 +1146,7 @@ RUN check_metadata() { \ && check_absent /root/.cache/node-gyp \ && check_absent /root/.cache/uv \ && check_absent /sandbox/.cache \ + && check_absent /sandbox/.hermes/managed-policy.json \ && check_absent /sandbox/.nemoclaw/hermes-cron-restore-drain.json \ && check_metadata /sandbox/.nemoclaw 'root:root 1755' \ && check_metadata /scripts/patch-bundled-npm-brace-expansion.mts 'root:root 444' \ @@ -1144,6 +1156,8 @@ RUN check_metadata() { \ && check_metadata /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py 'root:root 755' \ && check_metadata /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py 'root:root 755' \ && check_metadata /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py 'root:root 755' \ + && check_metadata /usr/local/lib/nemoclaw/managed_policy.py 'root:root 444' \ + && check_metadata /usr/local/share/nemoclaw/hermes-managed-policy.json 'root:root 444' \ && test ! -L /usr/local/bin/nemoclaw-managed-bootstrap \ && check_metadata /usr/local/bin/nemoclaw-managed-bootstrap 'root:root 755' \ && test ! -L /usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh \ diff --git a/agents/hermes/config/generate.ts b/agents/hermes/config/generate.ts index a27c624f74c..c2ad8653190 100644 --- a/agents/hermes/config/generate.ts +++ b/agents/hermes/config/generate.ts @@ -2,8 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { type HermesBuildSettings, readHermesBuildSettings } from "./build-env.ts"; -import { buildHermesConfig, finalizeHermesPlatformToolsets } from "./hermes-config.ts"; -import { buildHermesEnvLines } from "./hermes-env.ts"; +import { + buildHermesManagedPolicy, + finalizeHermesPlatformToolsets, + type HermesManagedPolicyV1, +} from "./managed-policy.ts"; import { discoverModelSpecificSetups } from "./model-specific-setup.ts"; import { type WrittenHermesConfig, writeHermesConfigFiles } from "./write-config.ts"; @@ -18,6 +21,7 @@ export type GeneratedHermesConfig = { settings: HermesBuildSettings; config: Record; envLines: string[]; + policy: HermesManagedPolicyV1; written: WrittenHermesConfig; }; @@ -40,13 +44,15 @@ export function generateHermesConfig({ { env, scriptDir }, ); - const config = buildHermesConfig(settings, env); - const envLines = buildHermesEnvLines(settings, env); + const policy = buildHermesManagedPolicy(settings, env); + const config = policy.config; + const envLines = policy.env_lines; finalizeHermesPlatformToolsets(config, settings); - const written = writeHermesConfigFiles(config, envLines, homeDir); + const written = writeHermesConfigFiles(config, envLines, policy, homeDir); log(`[config] Wrote ${written.configPath} (model=${settings.model}, provider=custom)`); log(`[config] Wrote ${written.envPath} (${written.envEntryCount} entries)`); + log(`[config] Wrote ${written.policyPath} (schema=${policy.schema_version})`); - return { settings, config, envLines, written }; + return { settings, config, envLines, policy, written }; } diff --git a/agents/hermes/config/hermes-config.ts b/agents/hermes/config/hermes-config.ts deleted file mode 100644 index d3c438b6cbb..00000000000 --- a/agents/hermes/config/hermes-config.ts +++ /dev/null @@ -1,332 +0,0 @@ -// 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 { - applyManagedToolConfig, - effectiveManagedToolGatewayPresets, - loadManagedToolGatewayMatrix, -} from "./managed-tool-gateway.ts"; -import { isObjectRecord } from "./object-record.ts"; - -const REMOTE_PLATFORM_TOOLSETS = [ - "web", - "browser", - "terminal", - "file", - "code_execution", - "vision", - "image_gen", - "skills", - "todo", - "memory", - "session_search", - "delegation", - "cronjob", - "nemoclaw", - "audio", -]; - -export const MANAGED_IMAGE_HERMES_SUPPORTED_PLATFORMS = [ - "telegram", - "discord", - "weixin", - "slack", - "whatsapp", - "teams", -] as const; - -// Hermes v0.19.0 also packages platform plugins and built-in adapters that are -// not yet supported by NemoClaw's messaging manifests. A neutral managed image -// must explicitly disable the complete installed surface, while keeping this -// list separate from the supported/activatable contract above. -export const MANAGED_IMAGE_HERMES_NEUTRAL_PLATFORMS = [ - "bluebubbles", - "dingtalk", - "discord", - "email", - "feishu", - "google_chat", - "homeassistant", - "irc", - "line", - "matrix", - "mattermost", - "msgraph_webhook", - "ntfy", - "photon", - "qqbot", - "raft", - "relay", - "signal", - "simplex", - "slack", - "sms", - "teams", - "telegram", - "wecom", - "wecom_callback", - "weixin", - "whatsapp", - "whatsapp_cloud", - "webhook", - "yuanbao", -] as const; - -function hermesApiMode(inferenceApi: string): string | null { - 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, - env: NodeJS.ProcessEnv = process.env, -): Record { - const remotePlatformToolsets = buildHermesRemotePlatformToolsets(settings); - const modelProviderName = "custom"; - const pickerProviderName = settings.upstreamProvider || "nemoclaw-inference"; - const modelConfig: Record = { - default: settings.model, - provider: modelProviderName, - base_url: settings.baseUrl, - api_key: "sk-OPENSHELL-PROXY-REWRITE", - }; - const apiMode = hermesApiMode(settings.inferenceApi); - if (apiMode) modelConfig.api_mode = apiMode; - // context_length on the model block is Hermes' highest-priority context - // override — above live /v1/models discovery and its built-in model-metadata - // registry. Setting it stops NemotronH-family models from falling back to a - // small architecture default when the endpoint actually serves a larger - // max_model_len (#6177). Omit it (null) to let Hermes auto-detect. Hermes - // reads only `context_length`; `context_window` is silently ignored. - // - // No separate auxiliary/compression context key is written: Hermes derives - // its compression trigger (compression.threshold × context_length) from the - // main model's context_length, so setting it here is sufficient for the - // reported "Cannot compress further" failure — the auxiliary/curator model is - // configured via auxiliary.* and needs no dedicated context length here. - if (settings.contextWindow !== null) modelConfig.context_length = settings.contextWindow; - - // Surface the managed endpoint to Hermes' model picker. The inline `model:` - // block above is enough for the gateway to ROUTE inference, but the picker - // (CLI `hermes model` and the dashboard Models page via /api/model/options) - // enumerates providers through get_compatible_custom_providers(), which only - // reads `custom_providers:` / `providers:` — never the inline `model:` block. - // Without an entry here the picker shows zero models even though inference - // works. Mirror the same proxied endpoint and let Hermes live-discover the - // available models from /v1/models (served by the OpenShell inference proxy; - // GET /v1/models is allowlisted in policy-additions.yaml). discover_models is - // Hermes' default, but we set it explicitly so the intent survives upstream - // default changes, and we omit an explicit `models:` list precisely so the - // picker reflects the live catalog rather than a single hard-coded id. - const customProvider: Record = { - name: pickerProviderName, - base_url: settings.baseUrl, - api_key: "sk-OPENSHELL-PROXY-REWRITE", - discover_models: true, - }; - if (apiMode) customProvider.api_mode = apiMode; - const providerConfig: Record = { - name: pickerProviderName, - api: settings.baseUrl, - api_key: "sk-OPENSHELL-PROXY-REWRITE", - default_model: settings.model, - discover_models: true, - }; - if (apiMode) providerConfig.transport = apiMode; - - const upstream: Record = { - provider: settings.upstreamProvider, - model: settings.model, - }; - - const platforms: Record = { - api_server: { - enabled: true, - extra: { - port: 18642, - host: "127.0.0.1", - }, - }, - }; - if (settings.managedImageCapabilityUnion) { - for (const platform of MANAGED_IMAGE_HERMES_NEUTRAL_PLATFORMS) { - platforms[platform] = { enabled: false }; - } - } - - const config: Record = { - _config_version: 33, - _nemoclaw_upstream: upstream, - model: modelConfig, - providers: { - [pickerProviderName]: providerConfig, - }, - custom_providers: [customProvider], - terminal: { - backend: "local", - timeout: 180, - }, - agent: { - max_turns: 60, - // Hermes config migrations v30 -> v32 disable the old implicit - // verify-on-stop behavior once. Generated configs start at v32, so - // persist the same migrated value instead of inheriting "auto". - verify_on_stop: false, - }, - approvals: { - // Hermes 0.19 defaults an omitted mode to smart authorization. - // Keep automated command authorization behind a separate product decision. - mode: "manual", - }, - session_reset: { - // Hermes 0.19 changes an omitted gateway reset policy from daily plus - // idle expiry to no automatic reset. Preserve the complete prior policy - // so later dependency defaults cannot silently change retention or - // notification behavior. - mode: "both", - at_hour: 4, - idle_minutes: 1440, - notify: true, - notify_exclude_platforms: ["api_server", "webhook"], - bg_process_max_age_hours: 24, - }, - browser: { - // Hermes 0.19 makes the sensitive browser_console JavaScript primitive - // denylist opt-in. Preserve the prior fail-closed posture for hostile - // pages; a broader evaluation surface requires its own security decision. - restrict_evaluate: true, - }, - tools: { - tool_search: { - // Deliberately defer every MCP and non-core plugin tool, even for a - // small catalog. Hermes keeps its built-in core tools directly visible. - // Keep Hermes' native snake_case keys and 5/20 limits distinct from - // OpenClaw's camelCase Tool Search contract and 8-result default. - enabled: settings.toolDisclosure === "direct" ? "off" : "on", - search_default_limit: 5, - max_search_limit: 20, - }, - }, - memory: { - memory_enabled: true, - user_profile_enabled: true, - }, - skills: { - creation_nudge_interval: 15, - }, - display: { - compact: false, - tool_progress: "all", - interim_assistant_messages: true, - // Hermes 0.19 changes this default to true. Keep internal reasoning out - // of user-visible channel output unless product policy changes explicitly. - show_reasoning: false, - // Commentary is a new Hermes 0.19 visible-output channel. Keep the - // dependency upgrade from expanding channel disclosure by default. - show_commentary: false, - }, - updates: { - // Hermes 0.19 changes pre-update backups from off to a state snapshot - // and adds an automatic CUA driver refresh. NemoClaw owns image updates - // externally, so do not duplicate state or fetch mutable update payloads. - pre_update_backup: false, - refresh_cua_driver: false, - }, - curator: { - enabled: true, - interval_hours: 168, - min_idle_hours: 2, - stale_after_days: 30, - archive_after_days: 90, - consolidate: false, - prune_builtins: true, - backup: { - enabled: true, - keep: 5, - }, - }, - auxiliary: { - curator: { - provider: "auto", - model: "", - base_url: "", - api_key: "", - timeout: 600, - extra_body: {}, - }, - }, - plugins: { - enabled: ["nemoclaw"], - }, - platform_toolsets: { - api_server: remotePlatformToolsets, - }, - platforms, - }; - - const managedToolGatewayPresets = effectiveManagedToolGatewayPresets(settings); - if (managedToolGatewayPresets.length > 0) { - const matrix = loadManagedToolGatewayMatrix(env); - for (const preset of managedToolGatewayPresets) { - const entry = matrix[preset]; - if (!entry) { - throw new Error(`Unknown Hermes managed-tool gateway preset: ${preset}`); - } - applyManagedToolConfig(config, entry.config); - } - } - - // An explicitly selected Tavily credential takes precedence over the - // Nous-managed Firecrawl gateway. Replacing the whole section also removes - // `use_gateway: true`, which would otherwise keep Hermes on Firecrawl. - if (settings.webSearchProvider === "tavily") { - config.web = { backend: "tavily" }; - } - - return config; -} - -export function finalizeHermesPlatformToolsets( - config: Record, - settings: HermesBuildSettings, -): void { - addEnabledPlatformToolsets(config, buildHermesRemotePlatformToolsets(settings)); -} - -function buildHermesRemotePlatformToolsets(settings: HermesBuildSettings): string[] { - const remotePlatformToolsets = [...REMOTE_PLATFORM_TOOLSETS]; - if ( - settings.managedToolGateways.brokerEnabled && - settings.managedToolGateways.presets.includes("nous-audio") && - !remotePlatformToolsets.includes("tts") - ) { - remotePlatformToolsets.push("tts"); - } - return remotePlatformToolsets; -} - -function addEnabledPlatformToolsets( - config: Record, - remotePlatformToolsets: readonly string[], -): void { - const platformToolsets = config.platform_toolsets as Record; - const platforms = config.platforms as Record; - for (const [platform, platformConfig] of Object.entries(platforms)) { - if (platform === "api_server" || !isEnabledPlatform(platformConfig)) continue; - platformToolsets[platform] = [...remotePlatformToolsets]; - } -} - -function isEnabledPlatform(value: unknown): boolean { - return isObjectRecord(value) && value.enabled === true; -} diff --git a/agents/hermes/config/managed-policy.ts b/agents/hermes/config/managed-policy.ts new file mode 100644 index 00000000000..d95a6db43e2 --- /dev/null +++ b/agents/hermes/config/managed-policy.ts @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { HermesManagedRouting } from "../../../src/lib/hermes-managed-route.ts"; +import { applyHermesManagedRoute } from "../../../src/lib/hermes-managed-route.ts"; +import type { HermesBuildSettings } from "./build-env.ts"; +import { buildHermesEnvLines } from "./hermes-env.ts"; +import { + applyManagedToolConfig, + effectiveManagedToolGatewayPresets, + loadManagedToolGatewayMatrix, +} from "./managed-tool-gateway.ts"; +import { isObjectRecord } from "./object-record.ts"; + +export type { HermesManagedRoute } from "../../../src/lib/hermes-managed-route.ts"; +export { + applyHermesManagedRoute, + hermesApiMode, + hermesProviderKey, +} from "../../../src/lib/hermes-managed-route.ts"; + +export const HERMES_MANAGED_POLICY_SCHEMA_VERSION = 1 as const; + +const REMOTE_PLATFORM_TOOLSETS = [ + "web", + "browser", + "terminal", + "file", + "code_execution", + "vision", + "image_gen", + "skills", + "todo", + "memory", + "session_search", + "delegation", + "cronjob", + "nemoclaw", + "audio", +]; + +export const MANAGED_IMAGE_HERMES_SUPPORTED_PLATFORMS = [ + "telegram", + "discord", + "weixin", + "slack", + "whatsapp", + "teams", +] as const; + +// Hermes v0.19.0 also packages platform plugins and built-in adapters that are +// not yet supported by NemoClaw's messaging manifests. A neutral managed image +// must explicitly disable the complete installed surface, while keeping this +// list separate from the supported/activatable contract above. +export const MANAGED_IMAGE_HERMES_NEUTRAL_PLATFORMS = [ + "bluebubbles", + "dingtalk", + "discord", + "email", + "feishu", + "google_chat", + "homeassistant", + "irc", + "line", + "matrix", + "mattermost", + "msgraph_webhook", + "ntfy", + "photon", + "qqbot", + "raft", + "relay", + "signal", + "simplex", + "slack", + "sms", + "teams", + "telegram", + "wecom", + "wecom_callback", + "weixin", + "whatsapp", + "whatsapp_cloud", + "webhook", + "yuanbao", +] as const; + +const DASHBOARD_ROUTING_KEYS = [ + "model", + "providers", + "custom_providers", + "_nemoclaw_upstream", +] as const; + +const DASHBOARD_ENV_KEYS = [ + "API_SERVER_HOST", + "API_SERVER_PORT", + "TAVILY_API_KEY", + "NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER", + "FIRECRAWL_GATEWAY_URL", + "OPENAI_AUDIO_GATEWAY_URL", + "BROWSER_USE_GATEWAY_URL", + "FAL_QUEUE_GATEWAY_URL", + "MODAL_GATEWAY_URL", +] as const; + +const MANAGED_POLICY_PATHS = [ + "approvals.mode", + "browser.allow_unsafe_evaluate", + "browser.restrict_evaluate", + "session_reset.mode", + "session_reset.at_hour", + "session_reset.idle_minutes", + "session_reset.notify", + "session_reset.notify_exclude_platforms", + "session_reset.bg_process_max_age_hours", + "display.show_reasoning", + "display.show_commentary", + "updates.pre_update_backup", + "updates.refresh_cua_driver", +] as const; + +type HermesManagedConfigBase = Record & { + _config_version: number; + approvals: { mode: "manual" | "smart" | "off" }; + browser: { allow_unsafe_evaluate: boolean; restrict_evaluate: boolean }; + display: { + compact: boolean; + tool_progress: string; + interim_assistant_messages: boolean; + show_reasoning: boolean; + show_commentary: boolean; + }; + session_reset: { + mode: "daily" | "idle" | "both" | "none"; + at_hour: number; + idle_minutes: number; + notify: boolean; + notify_exclude_platforms: string[]; + bg_process_max_age_hours: number; + }; + updates: { pre_update_backup: boolean | string; refresh_cua_driver: boolean }; + tools: { + tool_search: { + enabled: "on" | "off"; + search_default_limit: number; + max_search_limit: number; + }; + }; +}; + +export type HermesManagedConfig = HermesManagedConfigBase & HermesManagedRouting; + +export type HermesManagedPolicyV1 = { + schema_version: typeof HERMES_MANAGED_POLICY_SCHEMA_VERSION; + config: HermesManagedConfig; + env_lines: string[]; + dashboard: { + routing_keys: [...typeof DASHBOARD_ROUTING_KEYS]; + env_keys: [...typeof DASHBOARD_ENV_KEYS]; + }; + managed_paths: [...typeof MANAGED_POLICY_PATHS]; +}; + +export function buildHermesManagedPolicy( + settings: HermesBuildSettings, + env: NodeJS.ProcessEnv = process.env, +): HermesManagedPolicyV1 { + const platforms: Record = { + api_server: { + enabled: true, + extra: { + port: 18642, + host: "127.0.0.1", + }, + }, + }; + if (settings.managedImageCapabilityUnion) { + for (const platform of MANAGED_IMAGE_HERMES_NEUTRAL_PLATFORMS) { + platforms[platform] = { enabled: false }; + } + } + + const config: HermesManagedConfigBase = { + _config_version: 33, + approvals: { + // Hermes 0.19 defaults an omitted mode to smart authorization. + // Automated command authorization needs a separate product decision. + mode: "manual", + }, + browser: { + // Keep unsafe and sensitive browser evaluation restricted for hostile pages. + allow_unsafe_evaluate: false, + restrict_evaluate: true, + }, + session_reset: { + // Preserve the prior daily and idle expiry instead of inheriting an + // upstream no-reset default. + mode: "both", + at_hour: 4, + idle_minutes: 1440, + notify: true, + notify_exclude_platforms: ["api_server", "webhook"], + bg_process_max_age_hours: 24, + }, + terminal: { + backend: "local", + timeout: 180, + }, + agent: { + max_turns: 60, + verify_on_stop: false, + }, + tools: { + tool_search: { + // Hermes keeps built-in core tools visible and defers the remaining + // catalog behind its native tool search. + enabled: settings.toolDisclosure === "direct" ? "off" : "on", + search_default_limit: 5, + max_search_limit: 20, + }, + }, + memory: { + memory_enabled: true, + user_profile_enabled: true, + }, + skills: { + creation_nudge_interval: 15, + }, + display: { + compact: false, + tool_progress: "all", + interim_assistant_messages: true, + show_reasoning: false, + show_commentary: false, + }, + updates: { + // NemoClaw owns image updates, so Hermes must not snapshot state or fetch + // a mutable CUA driver during its own update path. + pre_update_backup: false, + refresh_cua_driver: false, + }, + curator: { + enabled: true, + interval_hours: 168, + min_idle_hours: 2, + stale_after_days: 30, + archive_after_days: 90, + consolidate: false, + prune_builtins: true, + backup: { + enabled: true, + keep: 5, + }, + }, + auxiliary: { + curator: { + provider: "auto", + model: "", + base_url: "", + api_key: "", + timeout: 600, + extra_body: {}, + }, + }, + plugins: { + enabled: ["nemoclaw"], + }, + platform_toolsets: { + api_server: buildHermesRemotePlatformToolsets(settings), + }, + platforms, + }; + + applyHermesManagedRoute(config, { + model: settings.model, + baseUrl: settings.baseUrl, + upstreamProvider: settings.upstreamProvider, + inferenceApi: settings.inferenceApi, + contextWindow: settings.contextWindow, + }); + + const managedToolGatewayPresets = effectiveManagedToolGatewayPresets(settings); + if (managedToolGatewayPresets.length > 0) { + const matrix = loadManagedToolGatewayMatrix(env); + for (const preset of managedToolGatewayPresets) { + const entry = matrix[preset]; + if (!entry) throw new Error(`Unknown Hermes managed-tool gateway preset: ${preset}`); + applyManagedToolConfig(config, entry.config); + } + } + + // An explicit Tavily selection replaces managed Firecrawl settings. + if (settings.webSearchProvider === "tavily") config.web = { backend: "tavily" }; + + return { + schema_version: HERMES_MANAGED_POLICY_SCHEMA_VERSION, + config, + env_lines: buildHermesEnvLines(settings, env), + dashboard: { + routing_keys: [...DASHBOARD_ROUTING_KEYS], + env_keys: [...DASHBOARD_ENV_KEYS], + }, + managed_paths: [...MANAGED_POLICY_PATHS], + }; +} + +export function finalizeHermesPlatformToolsets( + config: Record, + settings: HermesBuildSettings, +): void { + addEnabledPlatformToolsets(config, buildHermesRemotePlatformToolsets(settings)); +} + +function buildHermesRemotePlatformToolsets(settings: HermesBuildSettings): string[] { + const remotePlatformToolsets = [...REMOTE_PLATFORM_TOOLSETS]; + if ( + settings.managedToolGateways.brokerEnabled && + settings.managedToolGateways.presets.includes("nous-audio") + ) { + remotePlatformToolsets.push("tts"); + } + return remotePlatformToolsets; +} + +function addEnabledPlatformToolsets( + config: Record, + remotePlatformToolsets: readonly string[], +): void { + const platformToolsets = config.platform_toolsets as Record; + const platforms = config.platforms as Record; + for (const [platform, platformConfig] of Object.entries(platforms)) { + if (platform === "api_server" || !isEnabledPlatform(platformConfig)) continue; + platformToolsets[platform] = [...remotePlatformToolsets]; + } +} + +function isEnabledPlatform(value: unknown): boolean { + return isObjectRecord(value) && value.enabled === true; +} diff --git a/agents/hermes/config/write-config.ts b/agents/hermes/config/write-config.ts index c8e89db130a..9a17d5bb2ac 100644 --- a/agents/hermes/config/write-config.ts +++ b/agents/hermes/config/write-config.ts @@ -4,6 +4,7 @@ import { chmodSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import type { HermesManagedPolicyV1 } from "./managed-policy.ts"; import { buildHermesUpstreamHeader } from "./upstream-header.ts"; import { toYaml } from "./yaml.ts"; @@ -11,11 +12,13 @@ export type WrittenHermesConfig = { configPath: string; envPath: string; envEntryCount: number; + policyPath: string; }; export function writeHermesConfigFiles( config: Record, envLines: string[], + policy: HermesManagedPolicyV1, homeDir: string = homedir(), ): WrittenHermesConfig { const configPath = join(homeDir, ".hermes", "config.yaml"); @@ -26,9 +29,14 @@ export function writeHermesConfigFiles( writeFileSync(envPath, envLines.length > 0 ? `${envLines.join("\n")}\n` : ""); chmodSync(envPath, 0o600); + const policyPath = join(homeDir, ".hermes", "managed-policy.json"); + writeFileSync(policyPath, `${JSON.stringify(policy, null, 2)}\n`); + chmodSync(policyPath, 0o600); + return { configPath, envPath, envEntryCount: envLines.length, + policyPath, }; } diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index b045c364966..feb39c4038f 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -29,26 +29,24 @@ # user running `hermes config show` sees an `sk-`-prefixed string that # looks like a real credential. # - Value being masked: for configs generated by -# `agents/hermes/config/hermes-config.ts:buildHermesConfig`, the literal -# placeholder `sk-OPENSHELL-PROXY-REWRITE` is hard-coded for the `model`, +# `agents/hermes/config/managed-policy.ts:buildHermesManagedPolicy`, the literal +# rewrite sentinel `sk-OPENSHELL-PROXY-REWRITE` is hard-coded for the `model`, # `providers`, and `custom_providers` `api_key` fields; the user's real # provider credential is never serialised into the rendered config # (requests are rewritten at the OpenShell egress boundary). The masker -# also unconditionally redacts any `api_key`-shaped field — including a -# real value supplied via the seed-routing fallback in -# `agents/hermes/seed-dashboard-config.py:_route_api_key` — so the -# post-mask user-visible stream is safe in every case. +# also unconditionally redacts any `api_key`-shaped field, so no +# `api_key` field value reaches the post-mask user-visible stream. # - Source-fix constraint: removing the inline `api_key` would require # either Hermes CLI native env-var reference support (an upstream # change) or a redesigned dashboard/runtime contract that no longer -# needs an `sk-`-prefixed placeholder in the rendered config. +# needs an `sk-`-prefixed rewrite sentinel in the rendered config. # - Regression test: `test/hermes-gateway-wrapper.test.ts` — -# `masks every api_key emitted by buildHermesConfig ...` derives a -# fixture from `buildHermesConfig()` and asserts no raw placeholder +# `masks every api_key emitted by the managed policy ...` derives a +# fixture from `buildHermesManagedPolicy()` and asserts no raw sentinel # survives in stdout for `config show`. # - Removal condition: delete the `config show` branch when Hermes CLI -# redacts credential-shaped fields natively or `buildHermesConfig` stops -# emitting an inline `api_key` value. +# redacts credential-shaped fields natively or `buildHermesManagedPolicy` +# stops emitting an inline `api_key` value. # # `hermes-cli-adapter-v1.json` owns the exact translated command forms, their # upstream version, rationale, and removal conditions. The image build validates @@ -76,13 +74,16 @@ # bypass: every path that launches the gateway now passes through the same # single-source-of-truth validator before the port is bound. # -# Only a small set of top-level commands are intercepted; all other hermes -# subcommands (dashboard, --version, ...) pass straight through unchanged. +# Only a small set of top-level commands are intercepted. Managed dashboard +# launches receive the local API bearer token through process environment after +# a descriptor-safe read, so the isolated dashboard home does not need a second +# credential-bearing dotenv file. Other subcommands pass through unchanged. import ast import json import os import re +import stat import subprocess import sys import tempfile @@ -96,6 +97,8 @@ # Mirror the same dev-fallback `start.sh` uses so an ad-hoc bash invocation # over a checkout still finds the guard. _GUARD_DEV_FILENAME = "validate-env-secret-boundary.py" +_DASHBOARD_API_SERVER_ENV_PATH = "NEMOCLAW_HERMES_DASHBOARD_API_SERVER_ENV" +_API_SERVER_KEY_RE = re.compile(r"^[0-9a-f]{64}$") _CLI_ADAPTER_DEV_FILENAME = "hermes-cli-adapter-v1.json" _HERMES_MAIN_DEV_FILENAME = "hermes-main.py" # Trusted absolute paths for the python3 interpreter, ordered most-preferred @@ -147,6 +150,59 @@ def _resolve_trusted_python3() -> str | None: return None +def _load_dashboard_api_server_key() -> bool: + """Load a managed dashboard token when startup supplies its source path. + + Direct, unmanaged ``hermes dashboard`` invocations preserve the upstream optional API + authentication behavior. Managed startup always supplies the gateway dotenv path + and fails closed when its generated token is absent or invalid. + """ + source_path = os.environ.pop(_DASHBOARD_API_SERVER_ENV_PATH, "") + if not source_path: + return True + + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + fd = -1 + try: + fd = os.open(source_path, flags) + source_stat = os.fstat(fd) + if not stat.S_ISREG(source_stat.st_mode): + raise ValueError("credential source is not a regular file") + with os.fdopen(fd, "r", encoding="utf-8", closefd=False) as handle: + values: list[str] = [] + for line in handle: + candidate = line.strip() + if candidate.startswith("export "): + candidate = candidate[len("export ") :].lstrip() + key, separator, value = candidate.partition("=") + if not separator or key.strip() != "API_SERVER_KEY": + continue + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + value = value[1:-1] + values.append(value) + if len(values) != 1 or _API_SERVER_KEY_RE.fullmatch(values[0]) is None: + raise ValueError("credential source has no unique generated token") + except (OSError, UnicodeError, ValueError): + print( + "[SECURITY] Refusing hermes dashboard: API server credential source " + "is invalid or unreadable", + file=sys.stderr, + ) + return False + finally: + if fd >= 0: + try: + os.close(fd) + except OSError: + # Never retry close: EINTR leaves descriptor state unspecified, + # and O_CLOEXEC keeps the source out of the dashboard process. + pass + + os.environ["API_SERVER_KEY"] = values[0] + return True + + _MASKER_STDERR_ALLOWED_PREFIX = "[SECURITY]" _MASKER_STDERR_MAX_BYTES = 10 * 1024 * 1024 @@ -689,6 +745,8 @@ def _report_cli_adapter_error(exc: _CliAdapterError) -> int: def main(argv: list[str]) -> int: real_hermes = _resolve_real_hermes() guard_path = _resolve_guard() + if argv[:1] == ["dashboard"] and not _load_dashboard_api_server_key(): + return 1 if argv[:2] == ["config", "show"]: return _run_config_show(real_hermes, guard_path, argv) if argv[:1] == ["gateway"]: diff --git a/agents/hermes/image-build-probes.py b/agents/hermes/image-build-probes.py index abf2bbf42b0..d9875433458 100644 --- a/agents/hermes/image-build-probes.py +++ b/agents/hermes/image-build-probes.py @@ -9,6 +9,25 @@ from collections.abc import Callable from pathlib import Path +sys.path.insert(0, "/usr/local/lib/nemoclaw") + + +def _verify_profile_config_policy(config: dict, expected: dict[str, object]) -> None: + from managed_policy import policy_value + + for path, value in expected.items(): + if path.startswith("session_reset."): + continue + actual = policy_value(config, path) + assert actual == value, (path, actual, value) + + +def _verify_session_reset_policy(reset_policy: object, expected: dict[str, object]) -> None: + for field in ("mode", "at_hour", "idle_minutes"): + path = f"session_reset.{field}" + actual = getattr(reset_policy, field) + assert actual == expected[path], (path, actual, expected[path]) + def verify_profile_policy() -> None: from types import SimpleNamespace @@ -18,38 +37,25 @@ def verify_profile_policy() -> None: from hermes_cli import config as hermes_config from hermes_cli.config import load_config_readonly from hermes_cli.main import _resolve_pre_update_backup_mode + from managed_policy import load_managed_policy, profile_default_values from tools.browser_tool import ( _allow_unsafe_browser_evaluate, _restrict_browser_evaluate, ) from tui_gateway.server import _load_show_reasoning + policy = load_managed_policy() + expected = profile_default_values(policy) config = load_config_readonly() - assert config["approvals"]["mode"] == "manual", config["approvals"] - assert config["browser"]["allow_unsafe_evaluate"] is False, config["browser"] - assert config["browser"]["restrict_evaluate"] is True, config["browser"] - assert config["display"]["show_reasoning"] is False, config["display"] - assert config["display"]["show_commentary"] is False, config["display"] - assert config["updates"]["pre_update_backup"] is False, config["updates"] - assert config["updates"]["refresh_cua_driver"] is False, config["updates"] - assert CLI_CONFIG["display"]["show_reasoning"] is False, CLI_CONFIG["display"] - assert _allow_unsafe_browser_evaluate() is False - assert _restrict_browser_evaluate() is True - assert _load_show_reasoning() is False - assert SessionResetPolicy().mode == "both" - assert SessionResetPolicy.from_dict({}).mode == "both" + _verify_profile_config_policy(config, expected) + assert CLI_CONFIG["display"]["show_reasoning"] == expected["display.show_reasoning"] + assert _allow_unsafe_browser_evaluate() == expected["browser.allow_unsafe_evaluate"] + assert _restrict_browser_evaluate() == expected["browser.restrict_evaluate"] + assert _load_show_reasoning() == expected["display.show_reasoning"] + _verify_session_reset_policy(SessionResetPolicy(), expected) + _verify_session_reset_policy(SessionResetPolicy.from_dict({}), expected) gateway = load_gateway_config() - assert gateway.default_reset_policy.mode == "both", gateway.default_reset_policy - assert gateway.default_reset_policy.at_hour == 4, gateway.default_reset_policy - assert gateway.default_reset_policy.idle_minutes == 1440, gateway.default_reset_policy - tui_source = Path("/opt/hermes/tui_gateway/server.py").read_text(encoding="utf-8") - assert tui_source.count('.get("show_reasoning", True)') == 0 - assert tui_source.count('.get("show_reasoning", False)') == 2 - agent_source = Path("/opt/hermes/agent/agent_init.py").read_text(encoding="utf-8") - assert agent_source.count("agent.show_commentary = True") == 0 - assert agent_source.count("agent.show_commentary = False") == 2 - assert agent_source.count('.get("show_commentary", True)') == 0 - assert agent_source.count('.get("show_commentary", False)') == 1 + _verify_session_reset_policy(gateway.default_reset_policy, expected) original_load_config = hermes_config.load_config try: @@ -58,16 +64,14 @@ def fail_config_load(): hermes_config.load_config = fail_config_load args = SimpleNamespace(no_backup=False, backup=False) - assert _resolve_pre_update_backup_mode(args) == "off" + expected_backup_mode = ( + "off" + if expected["updates.pre_update_backup"] is False + else str(expected["updates.pre_update_backup"]) + ) + assert _resolve_pre_update_backup_mode(args) == expected_backup_mode finally: hermes_config.load_config = original_load_config - main_source = Path("/opt/hermes/hermes_cli/main.py").read_text(encoding="utf-8") - assert main_source.count('updates_cfg.get("pre_update_backup", "quick")') == 0 - assert main_source.count('updates_cfg.get("pre_update_backup", False)') == 1 - assert main_source.count("refresh_cua_driver = True") == 0 - assert main_source.count("refresh_cua_driver = False") == 1 - assert main_source.count('_update_cfg.get("refresh_cua_driver", True)') == 0 - assert main_source.count('_update_cfg.get("refresh_cua_driver", False)') == 1 def verify_gateway_runtime_metadata() -> None: @@ -244,30 +248,14 @@ def verify_langfuse_credentials() -> None: def verify_dashboard_policy(path: Path) -> None: import yaml + from managed_policy import load_managed_policy, policy_value config = yaml.safe_load(path.read_text(encoding="utf-8")) - expected = { - "approvals": {"mode": "manual"}, - "browser": {"restrict_evaluate": True}, - "session_reset": { - "mode": "both", - "at_hour": 4, - "idle_minutes": 1440, - "notify": True, - "notify_exclude_platforms": ["api_server", "webhook"], - "bg_process_max_age_hours": 24, - }, - "display": { - "show_reasoning": False, - "show_commentary": False, - }, - "updates": { - "pre_update_backup": False, - "refresh_cua_driver": False, - }, - } - for section, values in expected.items(): - assert config.get(section) == values, (section, config.get(section), values) + policy = load_managed_policy() + for dotted_path in policy["managed_paths"]: + expected = policy_value(policy["config"], dotted_path) + actual = policy_value(config, dotted_path) + assert actual == expected, (dotted_path, actual, expected) path.unlink() diff --git a/agents/hermes/managed_policy.py b/agents/hermes/managed_policy.py new file mode 100644 index 00000000000..9b4cdd770b1 --- /dev/null +++ b/agents/hermes/managed_policy.py @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Read the versioned policy manifest emitted by the Hermes TypeScript model.""" + +from __future__ import annotations + +import errno +import json +import os +import stat +from pathlib import Path + +MANAGED_POLICY_PATH = Path("/usr/local/share/nemoclaw/hermes-managed-policy.json") +MANAGED_POLICY_SCHEMA_VERSION = 1 +HERMES_PROXY_REWRITE_SENTINEL = "sk-OPENSHELL-PROXY-REWRITE" + + +class ManagedPolicyError(Exception): + pass + + +def _read_regular_text_no_follow(path: Path) -> str: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + fd = -1 + try: + fd = os.open(path, flags) + file_stat = os.fstat(fd) + if not stat.S_ISREG(file_stat.st_mode): + raise ManagedPolicyError("managed policy is not a regular file") + with os.fdopen(fd, "r", encoding="utf-8", closefd=False) as handle: + return handle.read() + except OSError as exc: + if exc.errno == errno.ELOOP: + raise ManagedPolicyError("managed policy is a symlink") from exc + raise ManagedPolicyError("managed policy is unreadable") from exc + finally: + if fd >= 0: + try: + os.close(fd) + except OSError: + pass + + +def _string_list(value: object, label: str) -> list[str]: + if ( + not isinstance(value, list) + or not value + or not all(isinstance(item, str) and item for item in value) + or len(set(value)) != len(value) + ): + raise ManagedPolicyError(f"{label} must be a non-empty list of unique strings") + return value + + +def load_managed_policy(path: Path = MANAGED_POLICY_PATH) -> dict: + try: + document = json.loads(_read_regular_text_no_follow(path)) + except json.JSONDecodeError as exc: + raise ManagedPolicyError("managed policy is malformed") from exc + if not isinstance(document, dict): + raise ManagedPolicyError("managed policy must be a mapping") + if set(document) != { + "schema_version", + "config", + "env_lines", + "dashboard", + "managed_paths", + }: + raise ManagedPolicyError("managed policy has an unexpected top-level shape") + version = document.get("schema_version") + if version != MANAGED_POLICY_SCHEMA_VERSION: + raise ManagedPolicyError( + f"managed policy schema {version!r} has no migration to " + f"{MANAGED_POLICY_SCHEMA_VERSION}" + ) + if not isinstance(document.get("config"), dict): + raise ManagedPolicyError("managed policy config must be a mapping") + _string_list(document.get("env_lines"), "managed policy env_lines") + dashboard = document.get("dashboard") + if not isinstance(dashboard, dict) or set(dashboard) != { + "routing_keys", + "env_keys", + }: + raise ManagedPolicyError("managed policy dashboard has an unexpected shape") + for key in ("routing_keys", "env_keys"): + _string_list(dashboard.get(key), f"managed policy dashboard.{key}") + managed_paths = _string_list( + document.get("managed_paths"), + "managed policy managed_paths", + ) + config = document["config"] + if policy_value(config, "model.api_key") != HERMES_PROXY_REWRITE_SENTINEL: + raise ManagedPolicyError( + "managed policy model.api_key must use the OpenShell proxy rewrite sentinel" + ) + for managed_path in managed_paths: + policy_value(config, managed_path) + for key in dashboard["routing_keys"]: + if key not in config: + raise ManagedPolicyError(f"managed policy config is missing {key}") + return document + + +def policy_value(config: dict, dotted_path: str) -> object: + value: object = config + for segment in dotted_path.split("."): + if not isinstance(value, dict) or segment not in value: + raise ManagedPolicyError(f"managed policy is missing {dotted_path}") + value = value[segment] + return value + + +def profile_default_values(policy: dict) -> dict[str, object]: + config = policy["config"] + return { + path: policy_value(config, path) + for path in policy["managed_paths"] + } diff --git a/agents/hermes/patch-profile-policy-defaults.py b/agents/hermes/patch-profile-policy-defaults.py index 1b1f2cf0000..a7866e3f17b 100755 --- a/agents/hermes/patch-profile-policy-defaults.py +++ b/agents/hermes/patch-profile-policy-defaults.py @@ -29,15 +29,32 @@ Every input file is bound to the exact upstream v2026.7.20 source hash before any edit. A Hermes upgrade must deliberately refresh these hashes and source shapes instead of silently carrying the patch forward. + +Delete this compatibility patch only when the pinned Hermes release applies +the managed-policy values to a config-less named profile across +``DEFAULT_CONFIG`` and every independent fallback listed above. The unmodified +upstream files must then pass the ``profile-policy`` image probe and +``test/hermes-profile-policy-defaults.test.ts``. """ from __future__ import annotations import argparse import hashlib +import json +import sys from pathlib import Path from typing import Iterable +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from managed_policy import ( # noqa: E402 + MANAGED_POLICY_PATH, + ManagedPolicyError, + load_managed_policy, + profile_default_values, +) + EXPECTED_SOURCE_SHA256 = { "config": "172b78ecb923048859ca177d96f5b010b44ec74bb1d13553577ff49bde1a071d", "browser": "02b4a0a0c8fc8b204c8f818dff1dd64295a817e5543b8a643198bcedbfbbcba2", @@ -48,130 +65,17 @@ "main": "d6bf89a33fb708376a7ab354cff8081a3c3726dbfb91d84bbb679cd667db596c", } -CONFIG_REPLACEMENTS = ( - ( - '"restrict_evaluate": False', - "# NemoClaw compatibility override: retain the outgoing denylist default.\n" - ' "restrict_evaluate": True', - ), - ( - '"show_reasoning": True', - "# NemoClaw compatibility override: reasoning remains hidden by default.\n" - ' "show_reasoning": False', - ), - ( - '"show_commentary": True', - "# NemoClaw compatibility override: commentary remains hidden by default.\n" - ' "show_commentary": False', - ), - ( - '"mode": "smart"', - "# NemoClaw compatibility override: flagged commands require manual approval.\n" - ' "mode": "manual"', - ), - ( - '"pre_update_backup": "quick"', - "# NemoClaw compatibility override: immutable images own update state.\n" - ' "pre_update_backup": False', - ), - ( - '"refresh_cua_driver": True', - "# NemoClaw compatibility override: immutable images do not fetch CUA updates.\n" - ' "refresh_cua_driver": False', - ), -) CONFIG_REQUIRED_UNCHANGED = ('"allow_unsafe_evaluate": False',) -BROWSER_REPLACEMENTS = ( - ( - 'return is_truthy_value(cfg_get(cfg, "browser", "restrict_evaluate"), ' - "default=False)", - "# NemoClaw compatibility override: missing raw YAML stays restricted.\n" - ' return is_truthy_value(cfg_get(cfg, "browser", "restrict_evaluate"), ' - "default=True)", - ), - ( - 'logger.debug("Could not read browser.restrict_evaluate from config: %s", e)\n' - " return False", - 'logger.debug("Could not read browser.restrict_evaluate from config: %s", e)\n' - " # NemoClaw compatibility override: config errors fail restricted.\n" - " return True", - ), -) - -GATEWAY_REPLACEMENTS = ( - ( - 'mode: str = "none" # "daily", "idle", "both", or "none"', - "# NemoClaw compatibility override: retain bounded daily and idle reset.\n" - ' mode: str = "both" # "daily", "idle", "both", or "none"', - ), - ( - 'mode=mode if mode is not None else "none"', - "# NemoClaw compatibility override: missing config keeps bounded reset.\n" - ' mode=mode if mode is not None else "both"', - ), -) - -CLI_REPLACEMENTS = ( - ( - '"show_reasoning": True', - "# NemoClaw compatibility override: reasoning remains hidden by default.\n" - ' "show_reasoning": False', - ), -) -TUI_REPLACEMENTS = ( - ( - "# Fallback True — keep in sync with DEFAULT_CONFIG display.show_reasoning\n" - " # (this loader reads the raw user YAML without the DEFAULT_CONFIG merge).\n" - ' return bool((_load_cfg().get("display") or {}).get("show_reasoning", True))', - "# NemoClaw compatibility override: missing raw YAML keeps reasoning hidden.\n" - ' return bool((_load_cfg().get("display") or {}).get("show_reasoning", False))', - 1, - ), - ( - 'if bool((cfg.get("display") or {}).get("show_reasoning", True))', - "# NemoClaw compatibility override: missing raw YAML stays hidden.\n" - ' if bool((cfg.get("display") or {}).get("show_reasoning", False))', - 1, - ), -) - -AGENT_REPLACEMENTS = ( - ( - "# Codex commentary visibility (display.show_commentary, default true).\n", - "# Codex commentary visibility. NemoClaw keeps the missing/error fallback off.\n", - ), - ( - "agent.show_commentary = True", - "agent.show_commentary = False # NemoClaw compatibility override: fail hidden.", - ), - ( - 'agent.show_commentary = bool(_display_section.get("show_commentary", True))', - "# NemoClaw compatibility override: a missing key keeps commentary hidden.\n" - " agent.show_commentary = bool(\n" - ' _display_section.get("show_commentary", False)\n' - " )", - ), -) - -MAIN_REPLACEMENTS = ( - ( - 'raw = updates_cfg.get("pre_update_backup", "quick")', - "# NemoClaw compatibility override: missing/error config skips state duplication.\n" - ' raw = updates_cfg.get("pre_update_backup", False)', - ), - ( - "refresh_cua_driver = True", - "# NemoClaw compatibility override: config errors do not fetch mutable CUA updates.\n" - " refresh_cua_driver = False", - ), - ( - '_update_cfg.get("refresh_cua_driver", True)', - '_update_cfg.get("refresh_cua_driver", False) ' - "# NemoClaw compatibility override: missing keys stay off.", - ), -) +def _literal(value: object) -> str: + if value is True: + return "True" + if value is False: + return "False" + if isinstance(value, str): + return json.dumps(value) + raise ValueError(f"unsupported managed policy literal type: {type(value).__name__}") def _sha256(source: str) -> str: @@ -198,7 +102,7 @@ def _replace_exact( return patched -def patch_config_source(source: str) -> str: +def patch_config_source(source: str, values: dict[str, object]) -> str: for shape in CONFIG_REQUIRED_UNCHANGED: count = source.count(shape) if count != 1: @@ -206,24 +110,106 @@ def patch_config_source(source: str) -> str: "Hermes config source shape changed for " f"{shape!r}: expected one occurrence, found {count}" ) - return _replace_exact(source, CONFIG_REPLACEMENTS, label="Hermes config") - - -def patch_browser_source(source: str) -> str: - return _replace_exact(source, BROWSER_REPLACEMENTS, label="Hermes browser policy") - - -def patch_gateway_source(source: str) -> str: - return _replace_exact(source, GATEWAY_REPLACEMENTS, label="Hermes gateway policy") - - -def patch_cli_source(source: str) -> str: - return _replace_exact(source, CLI_REPLACEMENTS, label="Hermes CLI policy") + replacements = ( + ( + '"restrict_evaluate": False', + "# NemoClaw compatibility override: generated policy restricts sensitive evaluation.\n" + f' "restrict_evaluate": {_literal(values["browser.restrict_evaluate"])}', + ), + ( + '"show_reasoning": True', + "# NemoClaw compatibility override: generated policy keeps reasoning hidden.\n" + f' "show_reasoning": {_literal(values["display.show_reasoning"])}', + ), + ( + '"show_commentary": True', + "# NemoClaw compatibility override: generated policy keeps commentary hidden.\n" + f' "show_commentary": {_literal(values["display.show_commentary"])}', + ), + ( + '"mode": "smart"', + "# NemoClaw compatibility override: generated policy requires manual approval.\n" + f' "mode": {_literal(values["approvals.mode"])}', + ), + ( + '"pre_update_backup": "quick"', + "# NemoClaw compatibility override: generated policy leaves image state unchanged.\n" + f' "pre_update_backup": {_literal(values["updates.pre_update_backup"])}', + ), + ( + '"refresh_cua_driver": True', + "# NemoClaw compatibility override: generated policy disables mutable CUA updates.\n" + f' "refresh_cua_driver": {_literal(values["updates.refresh_cua_driver"])}', + ), + ) + return _replace_exact(source, replacements, label="Hermes config") + + +def patch_browser_source(source: str, values: dict[str, object]) -> str: + expected = _literal(values["browser.restrict_evaluate"]) + replacements = ( + ( + 'return is_truthy_value(cfg_get(cfg, "browser", "restrict_evaluate"), default=False)', + "# NemoClaw compatibility override: missing raw YAML stays restricted.\n" + f' return is_truthy_value(cfg_get(cfg, "browser", "restrict_evaluate"), default={expected})', + ), + ( + 'logger.debug("Could not read browser.restrict_evaluate from config: %s", e)\n' + " return False", + 'logger.debug("Could not read browser.restrict_evaluate from config: %s", e)\n' + " # NemoClaw compatibility override: config errors fail restricted.\n" + f" return {expected}", + ), + ) + return _replace_exact(source, replacements, label="Hermes browser policy") + + +def patch_gateway_source(source: str, values: dict[str, object]) -> str: + expected = _literal(values["session_reset.mode"]) + replacements = ( + ( + 'mode: str = "none" # "daily", "idle", "both", or "none"', + "# NemoClaw compatibility override: generated policy bounds daily and idle reset.\n" + f' mode: str = {expected} # "daily", "idle", "both", or "none"', + ), + ( + 'mode=mode if mode is not None else "none"', + "# NemoClaw compatibility override: missing config keeps bounded reset.\n" + f" mode=mode if mode is not None else {expected}", + ), + ) + return _replace_exact(source, replacements, label="Hermes gateway policy") -def patch_tui_source(source: str) -> str: +def patch_cli_source(source: str, values: dict[str, object]) -> str: + replacements = (( + '"show_reasoning": True', + "# NemoClaw compatibility override: generated policy keeps reasoning hidden.\n" + f' "show_reasoning": {_literal(values["display.show_reasoning"])}', + ),) + return _replace_exact(source, replacements, label="Hermes CLI policy") + + +def patch_tui_source(source: str, values: dict[str, object]) -> str: + expected = _literal(values["display.show_reasoning"]) + replacements = ( + ( + "# Fallback True — keep in sync with DEFAULT_CONFIG display.show_reasoning\n" + " # (this loader reads the raw user YAML without the DEFAULT_CONFIG merge).\n" + ' return bool((_load_cfg().get("display") or {}).get("show_reasoning", True))', + "# NemoClaw compatibility override: missing raw YAML keeps reasoning hidden.\n" + f' return bool((_load_cfg().get("display") or {{}}).get("show_reasoning", {expected}))', + 1, + ), + ( + 'if bool((cfg.get("display") or {}).get("show_reasoning", True))', + "# NemoClaw compatibility override: missing raw YAML stays hidden.\n" + f' if bool((cfg.get("display") or {{}}).get("show_reasoning", {expected}))', + 1, + ), + ) patched = source - for old, new, expected_count in TUI_REPLACEMENTS: + for old, new, expected_count in replacements: old_count = patched.count(old) new_count = patched.count(new) if old_count != expected_count or new_count != 0: @@ -236,9 +222,27 @@ def patch_tui_source(source: str) -> str: return patched -def patch_agent_source(source: str) -> str: +def patch_agent_source(source: str, values: dict[str, object]) -> str: + expected = _literal(values["display.show_commentary"]) + replacements = ( + ( + "# Codex commentary visibility (display.show_commentary, default true).\n", + "# Codex commentary visibility is generated from NemoClaw's managed policy.\n", + ), + ( + "agent.show_commentary = True", + f"agent.show_commentary = {expected} # NemoClaw config-error fallback.", + ), + ( + 'agent.show_commentary = bool(_display_section.get("show_commentary", True))', + "# NemoClaw compatibility override: a missing key keeps commentary hidden.\n" + " agent.show_commentary = bool(\n" + f' _display_section.get("show_commentary", {expected})\n' + " )", + ), + ) patched = source - for old, new in AGENT_REPLACEMENTS: + for old, new in replacements: expected_count = 2 if old == "agent.show_commentary = True" else 1 old_count = patched.count(old) new_count = patched.count(new) @@ -252,11 +256,30 @@ def patch_agent_source(source: str) -> str: return patched -def patch_main_source(source: str) -> str: - return _replace_exact(source, MAIN_REPLACEMENTS, label="Hermes update policy") +def patch_main_source(source: str, values: dict[str, object]) -> str: + backup = _literal(values["updates.pre_update_backup"]) + refresh = _literal(values["updates.refresh_cua_driver"]) + replacements = ( + ( + 'raw = updates_cfg.get("pre_update_backup", "quick")', + "# NemoClaw compatibility override: missing config skips state duplication.\n" + f' raw = updates_cfg.get("pre_update_backup", {backup})', + ), + ( + "refresh_cua_driver = True", + "# NemoClaw compatibility override: config errors do not fetch CUA updates.\n" + f" refresh_cua_driver = {refresh}", + ), + ( + '_update_cfg.get("refresh_cua_driver", True)', + f'_update_cfg.get("refresh_cua_driver", {refresh}) ' + "# NemoClaw missing-key fallback.", + ), + ) + return _replace_exact(source, replacements, label="Hermes update policy") -def patch_file(path: Path, kind: str) -> None: +def patch_file(path: Path, kind: str, values: dict[str, object]) -> None: source = path.read_text(encoding="utf-8") actual_sha256 = _sha256(source) expected_sha256 = EXPECTED_SOURCE_SHA256[kind] @@ -276,7 +299,7 @@ def patch_file(path: Path, kind: str) -> None: "main": patch_main_source, }[kind] try: - patched = patcher(source) + patched = patcher(source, values) except ValueError as exc: raise SystemExit(f"ERROR: {exc}") from exc path.write_text(patched, encoding="utf-8") @@ -284,6 +307,12 @@ def patch_file(path: Path, kind: str) -> None: def main() -> int: parser = argparse.ArgumentParser() + parser.add_argument( + "--policy", + type=Path, + default=MANAGED_POLICY_PATH, + help="NemoClaw managed Hermes policy manifest", + ) parser.add_argument( "--config", default="/opt/hermes/hermes_cli/config.py", @@ -320,14 +349,18 @@ def main() -> int: help="Pinned Hermes main/update module", ) args = parser.parse_args() - - patch_file(Path(args.config), "config") - patch_file(Path(args.browser), "browser") - patch_file(Path(args.gateway), "gateway") - patch_file(Path(args.cli), "cli") - patch_file(Path(args.tui), "tui") - patch_file(Path(args.agent), "agent") - patch_file(Path(args.main), "main") + try: + values = profile_default_values(load_managed_policy(args.policy)) + except ManagedPolicyError as exc: + raise SystemExit(f"ERROR: {args.policy}: {exc}") from exc + + patch_file(Path(args.config), "config", values) + patch_file(Path(args.browser), "browser", values) + patch_file(Path(args.gateway), "gateway", values) + patch_file(Path(args.cli), "cli", values) + patch_file(Path(args.tui), "tui", values) + patch_file(Path(args.agent), "agent", values) + patch_file(Path(args.main), "main", values) return 0 diff --git a/agents/hermes/seed-dashboard-config.py b/agents/hermes/seed-dashboard-config.py index e97f1a5ac73..841733460cb 100755 --- a/agents/hermes/seed-dashboard-config.py +++ b/agents/hermes/seed-dashboard-config.py @@ -17,13 +17,9 @@ ``get_text_auxiliary_client``) resolve **no** client, because ``model.provider`` / ``model.base_url`` are empty so the auto-detect chain finds nothing. -This script mirrors the routing keys (``model``, ``custom_providers``, and the -informational ``_nemoclaw_upstream``), the exact native Tavily backend, and a -tight allowlist of reviewed policy leaves from the gateway config into the -dashboard config, preserving every other dashboard-local key. It also copies only the -dashboard-needed dotenv keys (local API server context and managed-tool gateway -URLs) into the dashboard ``HERMES_HOME`` when paths are supplied, because Hermes -0.16 moved parts of dashboard chat/model setup behind dotenv loading. +This script mirrors the routing keys, managed dashboard policy, and reviewed +dotenv keys declared by NemoClaw's versioned policy manifest. It preserves +dashboard-local keys outside that policy boundary. ``custom_providers`` carries ``discover_models: true`` so the dashboard live-lists ``/v1/models`` from the proxied endpoint rather than pinning a static catalog. It is idempotent: ``start.sh`` runs it on every launch so the dashboard stays in @@ -37,9 +33,8 @@ because the root entrypoint may invoke this helper over sandbox-writable paths. Usage: - seed-dashboard-config.py - seed-dashboard-config.py - seed-dashboard-config.py --merge-legacy + seed-dashboard-config.py [--merge-legacy] + seed-dashboard-config.py [--merge-legacy] Exits 0 on success or a benign no-op for a missing gateway config. Exits 1 when an existing config is invalid or unreadable, routing is absent, a @@ -53,54 +48,19 @@ import grp import os import pwd -import re import stat import sys +from copy import deepcopy +from pathlib import Path from typing import Callable, TextIO -# Keys mirrored from the gateway config into the dashboard config. Intentionally -# excludes platforms/plugins/messaging: the dashboard binds its own ports and -# must not inherit the gateway's api_server bind (port conflict) or channels. -_ROUTING_KEYS = ("model", "custom_providers", "_nemoclaw_upstream") -_APPROVAL_MODES = frozenset({"manual", "smart", "off"}) -_SESSION_RESET_MODES = frozenset({"daily", "idle", "both", "none"}) -_SESSION_RESET_KEYS = frozenset( - { - "mode", - "at_hour", - "idle_minutes", - "notify", - "notify_exclude_platforms", - "bg_process_max_age_hours", - } -) -_PRE_UPDATE_BACKUP_MODES = frozenset( - {"off", "false", "none", "disabled", "quick", "full", "zip", "true"} -) -_DASHBOARD_ENV_ALLOWED_KEYS = frozenset( - { - # Local API server context needed by dashboard chat/model calls. - "API_SERVER_HOST", - "API_SERVER_PORT", - "API_SERVER_KEY", - # This is a resolver placeholder, not a provider credential. It must - # remain exact so the dashboard cannot use this mirror to carry a raw - # Tavily key across the gateway/dashboard privilege boundary. - "TAVILY_API_KEY", - # Managed tool gateway broker URLs needed by dashboard-launched Hermes - # code paths. Do not copy messaging/provider/user credentials across - # this boundary; those stay in the gateway-owned .env. - "NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER", - "FIRECRAWL_GATEWAY_URL", - "OPENAI_AUDIO_GATEWAY_URL", - "BROWSER_USE_GATEWAY_URL", - "FAL_QUEUE_GATEWAY_URL", - "MODAL_GATEWAY_URL", - } -) -API_SERVER_KEY_RE = re.compile(r"^[0-9a-f]{64}$") -TAVILY_API_KEY_PLACEHOLDER = "openshell:resolve:env:TAVILY_API_KEY" +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from managed_policy import ( # noqa: E402 + ManagedPolicyError, + load_managed_policy, + policy_value, +) class UnsafeDashboardSeedPathError(Exception): pass @@ -110,10 +70,6 @@ class MissingDashboardSeedPathError(Exception): pass -class InvalidDashboardPolicyError(Exception): - pass - - class InvalidDashboardSeedDocumentError(Exception): pass @@ -399,13 +355,6 @@ def _lookup_gid(value: str) -> int: return int(value) if value.isdigit() else grp.getgrnam(value).gr_gid -def _is_generated_api_server_key(value: str) -> bool: - candidate = value.strip() - if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in ("'", '"'): - candidate = candidate[1:-1] - return API_SERVER_KEY_RE.fullmatch(candidate) is not None - - def _seed_owner_ids() -> tuple[int, int] | None: owner = os.environ.get("NEMOCLAW_DASHBOARD_SEED_OWNER", "").strip() if not owner: @@ -536,287 +485,90 @@ def _atomic_write_no_follow( os.close(opened_parent_fd) -def _provider_key(raw: object, fallback: str = "nemoclaw-inference") -> str: - value = str(raw or "").strip() - if not value: - value = fallback - key = value.lower().replace(" ", "-").replace("(", "").replace(")", "") - while "--" in key: - key = key.replace("--", "-") - return key.strip("-") or fallback - - -def _route_model_name(gateway: dict) -> str: - model = gateway.get("model") - if isinstance(model, dict): - for key in ("default", "model", "name"): - value = model.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - if isinstance(model, str) and model.strip(): - return model.strip() - upstream = gateway.get("_nemoclaw_upstream") - if isinstance(upstream, dict): - value = upstream.get("model") - if isinstance(value, str) and value.strip(): - return value.strip() - return "" - - -def _route_provider_name(gateway: dict) -> str: - upstream = gateway.get("_nemoclaw_upstream") - if isinstance(upstream, dict): - value = upstream.get("provider") - if isinstance(value, str) and value.strip(): - return value.strip() - model = gateway.get("model") - if isinstance(model, dict): - value = model.get("provider") - if isinstance(value, str) and value.strip() and value.strip().lower() != "custom": - return value.strip() - custom_providers = gateway.get("custom_providers") - if isinstance(custom_providers, list): - for entry in custom_providers: - if isinstance(entry, dict): - value = entry.get("name") - if isinstance(value, str) and value.strip(): - return value.strip() - providers = gateway.get("providers") - if isinstance(providers, dict) and providers: - return str(next(iter(providers.keys()))) - return "nemoclaw-inference" - - -def _route_base_url(gateway: dict) -> str: - model = gateway.get("model") - if isinstance(model, dict): - value = model.get("base_url") - if isinstance(value, str) and value.strip(): - return value.strip() - custom_providers = gateway.get("custom_providers") - if isinstance(custom_providers, list): - for entry in custom_providers: - if isinstance(entry, dict): - value = entry.get("base_url") or entry.get("api") or entry.get("url") - if isinstance(value, str) and value.strip(): - return value.strip() - providers = gateway.get("providers") - if isinstance(providers, dict): - for entry in providers.values(): - if isinstance(entry, dict): - value = entry.get("api") or entry.get("base_url") or entry.get("url") - if isinstance(value, str) and value.strip(): - return value.strip() - return "" - - -def _route_api_key(gateway: dict) -> str: - model = gateway.get("model") - if isinstance(model, dict): - value = model.get("api_key") - if isinstance(value, str) and value.strip(): - return value.strip() - custom_providers = gateway.get("custom_providers") - if isinstance(custom_providers, list): - for entry in custom_providers: - if isinstance(entry, dict): - value = entry.get("api_key") - if isinstance(value, str) and value.strip(): - return value.strip() - providers = gateway.get("providers") - if isinstance(providers, dict): - for entry in providers.values(): - if isinstance(entry, dict): - value = entry.get("api_key") - if isinstance(value, str) and value.strip(): - return value.strip() - return "sk-OPENSHELL-PROXY-REWRITE" - - -def _route_api_mode(gateway: dict) -> str: - model = gateway.get("model") - if isinstance(model, dict): - value = model.get("api_mode") - if isinstance(value, str) and value.strip(): - return value.strip() - custom_providers = gateway.get("custom_providers") - if isinstance(custom_providers, list): - for entry in custom_providers: - if isinstance(entry, dict): - value = entry.get("api_mode") or entry.get("transport") - if isinstance(value, str) and value.strip(): - return value.strip() - return "" - - -def _normalized_routing(gateway: dict) -> dict: - routing = {key: gateway[key] for key in _ROUTING_KEYS if key in gateway} - web = gateway.get("web") - if isinstance(web, dict) and web.get("backend") == "tavily": - # The backend selector is non-secret and must match the resolver-only - # TAVILY_API_KEY mirrored into the dashboard dotenv. Copy no other web - # settings across this privilege boundary. - routing["web"] = {"backend": "tavily"} - provider_name = _route_provider_name(gateway) - provider_key = _provider_key(provider_name) - model_name = _route_model_name(gateway) - base_url = _route_base_url(gateway) - api_key = _route_api_key(gateway) - api_mode = _route_api_mode(gateway) - - if model_name and base_url: - model = dict(routing.get("model") if isinstance(routing.get("model"), dict) else {}) - model.update( - { - "default": model_name, - "provider": provider_key, - "base_url": base_url, - "api_key": api_key, - } - ) - if api_mode: - model["api_mode"] = api_mode - routing["model"] = model - - provider_entry: dict = { - "name": provider_name, - "api": base_url, - "api_key": api_key, - "default_model": model_name, - "discover_models": True, - } - if api_mode: - provider_entry["transport"] = api_mode - providers = dict(gateway.get("providers") if isinstance(gateway.get("providers"), dict) else {}) - providers[provider_key] = provider_entry - routing["providers"] = providers - - if "custom_providers" not in routing: - custom_provider: dict = { - "name": provider_name, - "base_url": base_url, - "api_key": api_key, - "discover_models": True, - } - if api_mode: - custom_provider["api_mode"] = api_mode - routing["custom_providers"] = [custom_provider] - - return routing - - -def _policy_section(gateway: dict, name: str) -> dict: - value = gateway.get(name) - if not isinstance(value, dict): - raise InvalidDashboardPolicyError(f"{name} must be a mapping") - return value - - -def _policy_bool(section: dict, section_name: str, key: str) -> bool: - value = section.get(key) - if not isinstance(value, bool): - raise InvalidDashboardPolicyError(f"{section_name}.{key} must be a boolean") - return value - - -def _policy_int(section: dict, section_name: str, key: str, minimum: int, maximum: int) -> int: - value = section.get(key) - if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum: - raise InvalidDashboardPolicyError( - f"{section_name}.{key} must be an integer from {minimum} through {maximum}" - ) - return value - - -def _normalized_policy(gateway: dict) -> dict: - """Return only reviewed policy leaves, rejecting incomplete or invalid source policy.""" - approvals = _policy_section(gateway, "approvals") - approval_mode = approvals.get("mode") - if not isinstance(approval_mode, str) or approval_mode not in _APPROVAL_MODES: - raise InvalidDashboardPolicyError( - "approvals.mode must be one of manual, smart, or off" - ) - - browser = _policy_section(gateway, "browser") - restrict_evaluate = _policy_bool(browser, "browser", "restrict_evaluate") - - session_reset = _policy_section(gateway, "session_reset") - session_keys = frozenset(session_reset) - if session_keys != _SESSION_RESET_KEYS: - missing = sorted(_SESSION_RESET_KEYS - session_keys) - extra = sorted(session_keys - _SESSION_RESET_KEYS) - raise InvalidDashboardPolicyError( - f"session_reset must contain the reviewed complete policy " - f"(missing={missing}, extra={extra})" - ) - reset_mode = session_reset.get("mode") - if not isinstance(reset_mode, str) or reset_mode not in _SESSION_RESET_MODES: - raise InvalidDashboardPolicyError( - "session_reset.mode must be one of daily, idle, both, or none" - ) - at_hour = _policy_int(session_reset, "session_reset", "at_hour", 0, 23) - idle_minutes = _policy_int( - session_reset, - "session_reset", - "idle_minutes", - 1, - 2**31 - 1, - ) - notify = _policy_bool(session_reset, "session_reset", "notify") - excluded_platforms = session_reset.get("notify_exclude_platforms") +def _normalized_routing(gateway: dict, routing_keys: list[str], policy: dict) -> dict: + if any(key not in gateway for key in routing_keys): + raise InvalidDashboardSeedDocumentError("gateway config has incomplete model routing") + routing = {key: deepcopy(gateway[key]) for key in routing_keys} + upstream = routing.get("_nemoclaw_upstream") + model = routing.get("model") + providers = routing.get("providers") + custom_providers = routing.get("custom_providers") + if not isinstance(upstream, dict) or not isinstance(model, dict): + raise InvalidDashboardSeedDocumentError("gateway config has invalid model routing") + provider_key = upstream.get("provider_key") if ( - not isinstance(excluded_platforms, list) - or not excluded_platforms - or not all(isinstance(value, str) and value for value in excluded_platforms) + not isinstance(provider_key, str) + or not provider_key + or not isinstance(model.get("default"), str) + or not model.get("default") + or not isinstance(model.get("base_url"), str) + or not model.get("base_url") + or not isinstance(providers, dict) + or not isinstance(providers.get(provider_key), dict) + or not isinstance(custom_providers, list) + or not custom_providers ): - raise InvalidDashboardPolicyError( - "session_reset.notify_exclude_platforms must be a non-empty list of strings" + raise InvalidDashboardSeedDocumentError("gateway config has invalid model routing") + expected_api_key = policy_value(policy["config"], "model.api_key") + credential_bearing_routes = [model, *providers.values(), *custom_providers] + if not isinstance(expected_api_key, str) or any( + not isinstance(route, dict) or route.get("api_key") != expected_api_key + for route in credential_bearing_routes + ): + raise InvalidDashboardSeedDocumentError( + "gateway model routing contains a non-policy credential reference" ) - bg_process_max_age_hours = _policy_int( - session_reset, - "session_reset", - "bg_process_max_age_hours", - 1, - 2**31 - 1, - ) + model["provider"] = provider_key + return routing - display = _policy_section(gateway, "display") - show_reasoning = _policy_bool(display, "display", "show_reasoning") - show_commentary = _policy_bool(display, "display", "show_commentary") - updates = _policy_section(gateway, "updates") - pre_update_backup = updates.get("pre_update_backup") - if isinstance(pre_update_backup, str): - if pre_update_backup.strip().lower() not in _PRE_UPDATE_BACKUP_MODES: - raise InvalidDashboardPolicyError( - "updates.pre_update_backup has an unsupported mode" - ) - elif not isinstance(pre_update_backup, bool): - raise InvalidDashboardPolicyError( - "updates.pre_update_backup must be a boolean or supported mode string" +def _managed_policy_sections(gateway: dict, policy: dict) -> dict: + config = policy["config"] + section_names = {path.split(".", 1)[0] for path in policy["managed_paths"]} + for section_name in section_names: + if not _same_json_value(gateway.get(section_name), config.get(section_name)): + raise InvalidDashboardSeedDocumentError("gateway policy does not match managed policy") + + sections: dict = {} + for dotted_path in policy["managed_paths"]: + expected = policy_value(config, dotted_path) + _set_policy_value(sections, dotted_path, deepcopy(expected)) + expected_web = policy["config"].get("web") + gateway_web = gateway.get("web") + if expected_web is None: + if isinstance(gateway_web, dict) and "backend" in gateway_web: + raise InvalidDashboardSeedDocumentError("gateway policy does not match managed policy") + elif not isinstance(expected_web, dict) or not isinstance(gateway_web, dict): + raise InvalidDashboardSeedDocumentError("gateway policy does not match managed policy") + elif any(not _same_json_value(gateway_web.get(key), value) for key, value in expected_web.items()): + raise InvalidDashboardSeedDocumentError("gateway policy does not match managed policy") + return sections + + +def _set_policy_value(config: dict, dotted_path: str, value: object) -> None: + segments = dotted_path.split(".") + target = config + for segment in segments[:-1]: + child = target.setdefault(segment, {}) + if not isinstance(child, dict): + raise InvalidDashboardSeedDocumentError("managed policy path overlaps a value") + target = child + target[segments[-1]] = value + + +def _same_json_value(actual: object, expected: object) -> bool: + if type(actual) is not type(expected): + return False + if isinstance(expected, dict): + return set(actual) == set(expected) and all( + _same_json_value(actual[key], value) for key, value in expected.items() ) - refresh_cua_driver = _policy_bool(updates, "updates", "refresh_cua_driver") - - return { - "approvals": {"mode": approval_mode}, - "browser": {"restrict_evaluate": restrict_evaluate}, - "session_reset": { - "mode": reset_mode, - "at_hour": at_hour, - "idle_minutes": idle_minutes, - "notify": notify, - "notify_exclude_platforms": list(excluded_platforms), - "bg_process_max_age_hours": bg_process_max_age_hours, - }, - "display": { - "show_reasoning": show_reasoning, - "show_commentary": show_commentary, - }, - "updates": { - "pre_update_backup": pre_update_backup, - "refresh_cua_driver": refresh_cua_driver, - }, - } + if isinstance(expected, list): + return len(actual) == len(expected) and all( + _same_json_value(actual_item, expected_item) + for actual_item, expected_item in zip(actual, expected) + ) + return actual == expected def _merge_policy(dashboard: dict, policy: dict) -> None: @@ -828,9 +580,14 @@ def _merge_policy(dashboard: dict, policy: dict) -> None: dashboard[section_name] = merged -def _mirror_env(src: str, dst: str, *, dst_parent_fd: int | None = None) -> bool: - """Mirror allowlisted environment values into the dashboard profile.""" - +def _mirror_env( + src: str, + dst: str, + policy: dict, + *, + dst_parent_fd: int | None = None, +) -> bool: + """Mirror policy-allowlisted environment values into the dashboard profile.""" try: env_text = _read_regular_text_no_follow(src, "gateway env") except MissingDashboardSeedPathError: @@ -869,22 +626,22 @@ def parse_env_assignment(line: str) -> tuple[str, str] | None: key, value = candidate.split("=", 1) return key.strip(), value.strip() + allowed_keys = frozenset(policy["dashboard"]["env_keys"]) + expected_values = {} + for line in policy["env_lines"]: + parsed = parse_env_assignment(line) + if parsed is not None: + expected_values[parsed[0]] = parsed[1] + mirrored_lines: list[str] = [] for line in env_text.splitlines(keepends=True): parsed = parse_env_assignment(line) if parsed is None: continue key, value = parsed - if key not in _DASHBOARD_ENV_ALLOWED_KEYS: + if key not in allowed_keys: continue - if key == "API_SERVER_KEY" and not _is_generated_api_server_key(value): - print( - "[SECURITY] Refusing to seed dashboard env because API_SERVER_KEY " - "does not match the generated-token contract", - file=sys.stderr, - ) - return False - if key == "TAVILY_API_KEY" and value != TAVILY_API_KEY_PLACEHOLDER: + if key == "TAVILY_API_KEY" and value != expected_values.get(key): print( "[SECURITY] Refusing to seed dashboard env because TAVILY_API_KEY " "is not the canonical OpenShell resolver placeholder", @@ -910,12 +667,21 @@ def write_env(dst_handle: TextIO) -> None: def _seed_dashboard(argv: list[str], dashboard_fd: int | None) -> int: - """Seed dashboard state using an optional validated profile descriptor.""" - src, dst = argv[1], argv[2] + """Seed dashboard state using a validated policy and profile descriptor.""" + if len(argv) not in (4, 6): + print( + "[dashboard] usage: seed-dashboard-config.py " + " " + "[ ]", + file=sys.stderr, + ) + return 1 + + policy_path, src, dst = argv[1], argv[2], argv[3] env_parent_fd = ( dashboard_fd - if len(argv) == 5 - and os.path.normpath(os.path.dirname(argv[4])) == os.path.normpath(os.path.dirname(dst)) + if len(argv) == 6 + and os.path.normpath(os.path.dirname(argv[5])) == os.path.normpath(os.path.dirname(dst)) else None ) @@ -928,6 +694,15 @@ def _seed_dashboard(argv: list[str], dashboard_fd: int | None) -> int: ) return 1 + try: + policy = load_managed_policy(Path(policy_path)) + except ManagedPolicyError: + print( + "[SECURITY] Refusing to seed dashboard config because managed policy is invalid or unreadable", + file=sys.stderr, + ) + return 1 + try: gateway = _load_yaml(src, "gateway config") except MissingDashboardSeedPathError: @@ -935,8 +710,10 @@ def _seed_dashboard(argv: list[str], dashboard_fd: int | None) -> int: # error: there is simply nothing to mirror. print(f"[dashboard] gateway config {src} missing; skipping model seed", file=sys.stderr) env_ok = True - if len(argv) == 5: - env_ok = _mirror_env(argv[3], argv[4], dst_parent_fd=env_parent_fd) + if len(argv) == 6: + env_ok = _mirror_env( + argv[4], argv[5], policy, dst_parent_fd=env_parent_fd + ) return 0 if env_ok else 1 except UnsafeDashboardSeedPathError as exc: print(f"[SECURITY] Refusing to seed dashboard config because {exc}", file=sys.stderr) @@ -950,16 +727,17 @@ def _seed_dashboard(argv: list[str], dashboard_fd: int | None) -> int: ) return 1 - routing = _normalized_routing(gateway) - if not routing.get("model") and not routing.get("custom_providers") and not routing.get("providers"): + try: + routing = _normalized_routing(gateway, policy["dashboard"]["routing_keys"], policy) + except InvalidDashboardSeedDocumentError: print( - "[SECURITY] Refusing to seed dashboard config because gateway config has no model routing", + "[SECURITY] Refusing to seed dashboard config because gateway config has invalid model routing", file=sys.stderr, ) return 1 try: - policy = _normalized_policy(gateway) - except InvalidDashboardPolicyError: + policy_sections = _managed_policy_sections(gateway, policy) + except InvalidDashboardSeedDocumentError: print( "[SECURITY] Refusing to seed dashboard config because gateway policy is invalid", file=sys.stderr, @@ -987,16 +765,14 @@ def _seed_dashboard(argv: list[str], dashboard_fd: int | None) -> int: # Validate both YAML documents before mirroring dotenv or replacing either # config. A malformed policy source must not partially update the dashboard # environment before startup refuses the config. - if len(argv) == 5 and not _mirror_env( - argv[3], - argv[4], - dst_parent_fd=env_parent_fd, + if len(argv) == 6 and not _mirror_env( + argv[4], argv[5], policy, dst_parent_fd=env_parent_fd ): return 1 # The seeder owns only web.backend. Merge or remove that field while # preserving unrelated dashboard-local web settings. - managed_web = routing.pop("web", None) + managed_web = policy["config"].get("web") dashboard_web = dict(dashboard.get("web") if isinstance(dashboard.get("web"), dict) else {}) if isinstance(managed_web, dict) and managed_web.get("backend") == "tavily": dashboard_web["backend"] = "tavily" @@ -1007,7 +783,7 @@ def _seed_dashboard(argv: list[str], dashboard_fd: int | None) -> int: else: dashboard.pop("web", None) dashboard.update(routing) - _merge_policy(dashboard, policy) + _merge_policy(dashboard, policy_sections) import yaml @@ -1030,17 +806,17 @@ def main(argv: list[str]) -> int: """Validate arguments, anchor the destination, and run the dashboard seed.""" merge_legacy = len(argv) > 1 and argv[1] == "--merge-legacy" seed_argv = [argv[0], *argv[2:]] if merge_legacy else argv - if len(seed_argv) not in (3, 5): + if len(seed_argv) not in (4, 6): print( "[dashboard] usage: seed-dashboard-config.py " - "[--merge-legacy] " + "[--merge-legacy] " "[ ]", file=sys.stderr, ) return 1 destination_ok, dashboard_fd = _prepare_dashboard_destination( - seed_argv[2], merge_legacy=merge_legacy + seed_argv[3], merge_legacy=merge_legacy ) if not destination_ok: return 1 diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index d7aa615f18f..27cd7245024 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -223,6 +223,7 @@ _HERMES_DASHBOARD_CONFIG_SEEDER="/usr/local/lib/nemoclaw/seed-hermes-dashboard-c if [ ! -f "$_HERMES_DASHBOARD_CONFIG_SEEDER" ]; then _HERMES_DASHBOARD_CONFIG_SEEDER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/seed-dashboard-config.py" fi +_HERMES_MANAGED_POLICY="/usr/local/share/nemoclaw/hermes-managed-policy.json" # Descriptor-safe updater for runtime-mutable Hermes config/env/hash files. _HERMES_RUNTIME_CONFIG_GUARD="/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py" @@ -1469,6 +1470,7 @@ prepare_hermes_dashboard_home() { HERMES_DASHBOARD_HOME="$HERMES_DASHBOARD_HOME" \ _HERMES_PYTHON="$_HERMES_PYTHON" \ _HERMES_DASHBOARD_CONFIG_SEEDER="$_HERMES_DASHBOARD_CONFIG_SEEDER" \ + _HERMES_MANAGED_POLICY="$_HERMES_MANAGED_POLICY" \ "${STEP_DOWN_PREFIX_SANDBOX[@]}" sh -c ' if [ -L "$HERMES_DASHBOARD_HOME" ]; then echo "[SECURITY] Refusing Hermes dashboard startup because ${HERMES_DASHBOARD_HOME} is a symlink" >&2 @@ -1486,6 +1488,7 @@ prepare_hermes_dashboard_home() { # state that poisons /api/status even while the real gateway is healthy. rm -f "${HERMES_DASHBOARD_HOME}/gateway_state.json" 2>/dev/null || true exec "$_HERMES_PYTHON" "$_HERMES_DASHBOARD_CONFIG_SEEDER" \ + "$_HERMES_MANAGED_POLICY" \ "${HERMES_DIR}/config.yaml" "${HERMES_DASHBOARD_HOME}/config.yaml" \ "${HERMES_DIR}/.env" "${HERMES_DASHBOARD_HOME}/.env" ' || rc=$? @@ -1509,10 +1512,10 @@ prepare_hermes_dashboard_home() { seed_hermes_dashboard_config } -# Mirror the gateway's model routing and dotenv context into the dashboard's -# isolated HERMES_HOME so its Models page (/api/model/options), Chat/TUI setup -# checks, and kanban specifier/dispatcher resolve the routed model. The -# dashboard runs under HERMES_DASHBOARD_HOME for privilege separation and +# Mirror the gateway's model routing and non-secret dotenv context into the +# dashboard's isolated HERMES_HOME so its Models page (/api/model/options), +# Chat/TUI setup checks, and kanban specifier/dispatcher resolve the routed +# model. The dashboard runs under HERMES_DASHBOARD_HOME for privilege separation and # otherwise only sees a Hermes-default config with an empty model. Idempotent: # refreshes the keys on every launch. Missing gateway config is a benign no-op # in the seeder; security refusals and write failures abort startup. @@ -1526,6 +1529,7 @@ seed_hermes_dashboard_config() { # prepare_hermes_dashboard_home after stepping down to the sandbox identity. rm -f "${HERMES_DASHBOARD_HOME}/gateway_state.json" 2>/dev/null || true env "$_HERMES_PYTHON" "$_HERMES_DASHBOARD_CONFIG_SEEDER" \ + "$_HERMES_MANAGED_POLICY" \ "${HERMES_DIR}/config.yaml" "$dst" \ "${HERMES_DIR}/.env" "$env_dst" || rc=$? @@ -1541,6 +1545,7 @@ start_hermes_dashboard_current_user() { prepare_restricted_log /tmp/dashboard.log "" 600 || return 1 HERMES_HOME="${HERMES_DASHBOARD_HOME}" \ GATEWAY_HEALTH_URL="http://127.0.0.1:${INTERNAL_PORT}" \ + NEMOCLAW_HERMES_DASHBOARD_API_SERVER_ENV="${HERMES_DIR}/.env" \ nohup "$HERMES" "${HERMES_DASHBOARD_ARGS[@]}" >/tmp/dashboard.log 2>&1 & DASHBOARD_PID=$! echo "[gateway] hermes dashboard launched (pid $DASHBOARD_PID)" >&2 @@ -1559,6 +1564,7 @@ start_hermes_dashboard_sandbox_user() { prepare_restricted_log /tmp/dashboard.log sandbox:sandbox 600 || return 1 HERMES_HOME="${HERMES_DASHBOARD_HOME}" \ GATEWAY_HEALTH_URL="http://127.0.0.1:${INTERNAL_PORT}" \ + NEMOCLAW_HERMES_DASHBOARD_API_SERVER_ENV="${HERMES_DIR}/.env" \ nohup "${STEP_DOWN_PREFIX_SANDBOX[@]}" sh -c 'umask 0077; exec "$@" >/tmp/dashboard.log 2>&1' sh "$HERMES" "${HERMES_DASHBOARD_ARGS[@]}" & DASHBOARD_PID=$! echo "[gateway] hermes dashboard launched as 'sandbox' user (pid $DASHBOARD_PID)" >&2 diff --git a/docs/inference/configure-model-limits.mdx b/docs/inference/configure-model-limits.mdx index 75fa62efe2f..258a50d3b06 100644 --- a/docs/inference/configure-model-limits.mdx +++ b/docs/inference/configure-model-limits.mdx @@ -9,8 +9,8 @@ keywords: ["nemoclaw context window", "nemoclaw max tokens", "model limits"] content: type: "how_to" --- -Configure model limits before onboarding so NemoClaw can bake them into the sandbox image. -Changing a build-time model limit on an existing sandbox requires fresh recreation. +Configure explicit model limits before onboarding so NemoClaw can bake them into the sandbox image. +Changing an explicit build-time model limit on an existing sandbox requires fresh recreation. ## Set OpenClaw Limits @@ -52,6 +52,8 @@ $$nemoclaw onboard When onboarding resolves a valid value, NemoClaw writes it as `model.context_length` in `/sandbox/.hermes/config.yaml`. For non-Ollama endpoints, the field remains unset when no explicit or probed value is available so Hermes can auto-detect it from the endpoint. +During `inference set`, NemoClaw recomputes the context window for the target model. +It writes `model.context_length` when it resolves a value and omits the field when Hermes must use endpoint auto-discovery. When NemoClaw starts Local Ollama on macOS or Linux, it requests at least `64000` tokens. Fresh onboarding then verifies the loaded model's actual `context_length` through `/api/ps`. Resumed onboarding and sandbox rebuilds warm the exact recorded Ollama model and repeat this verification before reusing its route. @@ -91,4 +93,4 @@ Use this recreation path for Deep Agents context-window or maximum-token changes ## Related Topics - [Configure Inference Timeouts](configure-inference-timeouts) for request, validation, and readiness budgets. -- [Switch Models](switch-models) to change the model without changing model limits. +- [Switch Models](switch-models) to change the selected model. diff --git a/docs/inference/model-capability-audit.mdx b/docs/inference/model-capability-audit.mdx index 57a9dd9808e..a397f35b388 100644 --- a/docs/inference/model-capability-audit.mdx +++ b/docs/inference/model-capability-audit.mdx @@ -126,7 +126,7 @@ When importing a completed row from an issue comment, preserve the exact commit | OpenClaw primary agent | Local vLLM | Any model from `VLLM_MODELS`. | Managed `inference.local` route to the host vLLM server. | `not-yet-run` | Add vLLM serve flags, model id, and trajectory evidence before changing state. | Record parser flags, reasoning parser, and tool-call parser behavior. | Add one row per audited vLLM model id. | `src/lib/inference/vllm-models.ts`, `src/lib/inference/config.ts`. | | OpenClaw primary agent | Other OpenAI-compatible endpoint | User-selected `custom-model` or another configured model id. | Managed `inference.local` route to the compatible endpoint. | `not-yet-run` | Add endpoint class and trajectory evidence before changing state. | Record endpoint API path forcing and store/streaming assumptions. | Add one row per endpoint class that is validated. | `src/lib/inference/config.ts`. | | OpenClaw primary agent | Other Anthropic-compatible endpoint | User-selected `custom-anthropic-model` or another configured model id. | `anthropic` route when supported, otherwise managed compatible route. | `not-yet-run` | Add endpoint class and trajectory evidence before changing state. | Record native Anthropic Messages or compatible-route transport behavior. | Add one row per endpoint class that is validated. | `src/lib/inference/config.ts`. | -| Hermes sandbox API | Hermes Provider | Default `moonshotai/kimi-k2.6` or any model from `HERMES_PROVIDER_MODEL_OPTIONS`. | Hermes Provider route through NemoClaw managed inference. | `not-yet-run` | Add Hermes session, request dump, logs, and local API evidence before changing state. | Generated config uses native `tools.tool_search.enabled: on` with snake-case 5/20 limits; core tools stay direct while deferred MCP and non-core plugin tools use structured search, describe, and call. | Verify a deferred-tool trajectory and keep it separate from OpenClaw `mode: tools` evidence. | `agents/hermes/config/hermes-config.ts`, `test/generate-hermes-config.test.ts`. | +| Hermes sandbox API | Hermes Provider | Default `moonshotai/kimi-k2.6` or any model from `HERMES_PROVIDER_MODEL_OPTIONS`. | Hermes Provider route through NemoClaw managed inference. | `not-yet-run` | Add Hermes session, request dump, logs, and local API evidence before changing state. | Generated config uses native `tools.tool_search.enabled: on` with snake-case 5/20 limits; core tools stay direct while deferred MCP and non-core plugin tools use structured search, describe, and call. | Verify a deferred-tool trajectory and keep it separate from OpenClaw `mode: tools` evidence. | `agents/hermes/config/managed-policy.ts`, `test/generate-hermes-config.test.ts`. | | Deep Agents interactive `dcode` | NVIDIA Endpoints | `nvidia/nemotron-3-ultra-550b-a55b` | Managed `inference.local` OpenAI-compatible Chat Completions with `use_responses_api = false`. | `not-yet-run` | Add separate default-disabled and thread-opt-in terminal transcripts, host status output, reset evidence, and route evidence before changing state. | The managed Ultra profile preserves required nonempty tool-call content and rejects the observed literal `[content]` execute placeholder before shell dispatch; optional `thread-opt-in` remains a per-thread approval affordance. | Verify a terminal task with approval prompts intact, then separately verify explicit thread activation, reset behavior, policy enforcement, placeholder rejection, and no provider credential in sandbox-visible files. | `agents/langchain-deepagents-code/generate-config.ts`, `agents/langchain-deepagents-code/profile-plugin`, `docs/get-started/quickstart-langchain-deepagents-code`. | | Deep Agents headless `dcode -n` | NVIDIA Endpoints | `nvidia/nemotron-3-ultra-550b-a55b` | Managed `inference.local` OpenAI-compatible Chat Completions with `use_responses_api = false`. | `not-yet-run` | Add headless command transcript and status output before changing state. | Headless mode uses the managed Ultra profile, preserves required nonempty tool-call content, rejects the observed literal `[content]` execute placeholder, has no approval UI, and auto-approves non-shell tools while managed shell execution remains disabled. | Verify a bounded non-shell task, placeholder rejection, and the approval boundary separately from interactive evidence. | `agents/langchain-deepagents-code/dcode-wrapper.sh`, `agents/langchain-deepagents-code/profile-plugin`, `docs/security/best-practices`. | diff --git a/docs/inference/switch-models.mdx b/docs/inference/switch-models.mdx index d403e4cc606..a996b7b679a 100644 --- a/docs/inference/switch-models.mdx +++ b/docs/inference/switch-models.mdx @@ -43,7 +43,13 @@ You can also re-supply the same endpoint URL when the registry records that onbo NemoClaw requires an exact canonical match and does not extend that trust to a different URL or an endpoint recorded by `inference set`. If the route metadata is incomplete, NemoClaw stops and tells you to re-run onboarding. -For Hermes, the command also mirrors the selected model into the dashboard profile. + + + + +For Hermes, the command recomputes the target model's context window before it updates the main configuration and dashboard profile. +NemoClaw writes `model.context_length` when it resolves a value. +When it cannot resolve a value, NemoClaw omits the field so Hermes can discover the model metadata from the endpoint. If it reports that the Dashboard config did not converge, the route and main Hermes config remain committed; follow [Hermes dashboard config did not converge](../../reference/troubleshooting#hermes-dashboard-config-did-not-converge) before using Dashboard Chat. diff --git a/docs/manage-sandboxes/transfer-state-manually.mdx b/docs/manage-sandboxes/transfer-state-manually.mdx index 4665fc74c4b..49cc0669592 100644 --- a/docs/manage-sandboxes/transfer-state-manually.mdx +++ b/docs/manage-sandboxes/transfer-state-manually.mdx @@ -53,7 +53,8 @@ openshell sandbox download "$SANDBOX" /sandbox/.hermes/platforms/ "$BACKUP_DIR/p Copy only the dashboard profile's `MEMORY.md` and `USER.md` files. Do not copy `.hermes/profiles/dashboard-home/.env` or `.hermes/profiles/dashboard-home/config.yaml`. -NemoClaw regenerates both files, and `.env` contains dashboard authentication material. +NemoClaw regenerates both files from the managed policy and current inference route. +The dashboard process receives its API bearer token through the runtime environment instead of the mirrored `.env` file. If startup refuses a legacy profile migration, download the preserved user files for inspection: diff --git a/docs/security/credential-storage.mdx b/docs/security/credential-storage.mdx index 3f493a19c5e..25c7924f1a6 100644 --- a/docs/security/credential-storage.mdx +++ b/docs/security/credential-storage.mdx @@ -32,6 +32,9 @@ NemoClaw recreates generated Hermes runtime files during rebuilds. Those files should contain resolver placeholders, not live provider credentials. For managed tools and messaging, NemoClaw keeps host-side auth in OpenShell providers or host brokers and writes placeholder values into `/sandbox/.hermes/config.yaml`, `/sandbox/.hermes/.env`, and process environment entries visible to the sandbox. Hermes startup rejects raw secret-shaped values in those sandbox-visible surfaces. +The dashboard mirror at `/sandbox/.hermes/profiles/dashboard-home/.env` excludes `API_SERVER_KEY`. +The managed Hermes wrapper reads that token from `/sandbox/.hermes/.env` and supplies it only through the dashboard process environment. +Mirrored inference routing uses the OpenShell proxy rewrite sentinel instead of raw provider credentials. NemoClaw manages Deep Agents Code provider credentials through the same OpenShell provider boundary. diff --git a/src/lib/actions/inference-route-api.test.ts b/src/lib/actions/inference-route-api.test.ts index 6cdb584457a..452b33311d4 100644 --- a/src/lib/actions/inference-route-api.test.ts +++ b/src/lib/actions/inference-route-api.test.ts @@ -2,10 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it, vi } from "vitest"; +import { hermesApiMode } from "../hermes-managed-route"; import type { ConfigObject } from "../security/credential-filter"; import type { Session } from "../state/onboard-session"; import { - hermesApiMode, normalizeInferenceApi, readOpenClawPrimaryRouteApi, resolveRuntimeInferenceApi, diff --git a/src/lib/actions/inference-route-api.ts b/src/lib/actions/inference-route-api.ts index 0512f6bd85e..9f9c256a097 100644 --- a/src/lib/actions/inference-route-api.ts +++ b/src/lib/actions/inference-route-api.ts @@ -125,17 +125,3 @@ export function resolveRuntimeInferenceApi(options: { if (provider === "compatible-anthropic-endpoint") return "anthropic-messages"; return null; } - -export function hermesApiMode(inferenceApi: string): string | null { - switch (inferenceApi) { - case "": - case "openai-completions": - return null; - case "anthropic-messages": - return "anthropic_messages"; - case "openai-responses": - return "codex_responses"; - default: - return null; - } -} diff --git a/src/lib/actions/inference-set-hermes-run.test.ts b/src/lib/actions/inference-set-hermes-run.test.ts index 56b7121c142..7edbb03247b 100644 --- a/src/lib/actions/inference-set-hermes-run.test.ts +++ b/src/lib/actions/inference-set-hermes-run.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; -import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../hermes-proxy-api-key"; +import { HERMES_PROXY_REWRITE_SENTINEL } from "../hermes-managed-route"; import type { ConfigObject } from "../security/credential-filter"; import { runInferenceSet } from "./inference-set"; import { baseSession, createDeps, HERMES_TARGET } from "./inference-set.test-support"; @@ -28,6 +28,7 @@ describe("runInferenceSet Hermes routing", () => { defaultSandbox: "hermes", target: HERMES_TARGET, session: baseSession({ agent: "hermes", sandboxName: "hermes" }), + contextWindow: 128_000, }); const result = await runInferenceSet( @@ -57,13 +58,32 @@ describe("runInferenceSet Hermes routing", () => { expect(config).toEqual({ _nemoclaw_upstream: { provider: "hermes-provider", + provider_key: "hermes-provider", model: "openai/gpt-5.4-mini", }, + custom_providers: [ + { + name: "hermes-provider", + base_url: "https://inference.local/v1", + api_key: HERMES_PROXY_REWRITE_SENTINEL, + discover_models: true, + }, + ], model: { default: "openai/gpt-5.4-mini", provider: "custom", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, + context_length: 128_000, + }, + providers: { + "hermes-provider": { + name: "hermes-provider", + api: "https://inference.local/v1", + api_key: HERMES_PROXY_REWRITE_SENTINEL, + default_model: "openai/gpt-5.4-mini", + discover_models: true, + }, }, terminal: { backend: "local" }, }); @@ -103,6 +123,48 @@ describe("runInferenceSet Hermes routing", () => { sessionUpdated: true, }); expect(deps.calls.restartSandboxGateway).not.toHaveBeenCalled(); + expect(deps.calls.resolveContextWindowForModel).toHaveBeenCalledWith( + "hermes-provider", + "openai/gpt-5.4-mini", + ); + }); + + it("uses Hermes model discovery when the selected context window is unavailable", async () => { + const config: ConfigObject = { + model: { + default: "moonshotai/kimi-k2.6", + provider: "custom", + base_url: "https://inference.local/v1", + context_length: 32_768, + }, + }; + const deps = createDeps({ + config, + entry: { + name: "hermes", + agent: "hermes", + provider: "hermes-provider", + model: "moonshotai/kimi-k2.6", + }, + defaultSandbox: "hermes", + target: HERMES_TARGET, + session: baseSession({ agent: "hermes", sandboxName: "hermes" }), + contextWindow: null, + }); + + await runInferenceSet( + { + provider: "hermes-provider", + model: "openai/gpt-5.4-mini", + sandboxName: "hermes", + noVerify: true, + }, + deps, + ); + + expect(config.model).not.toHaveProperty("context_length"); + const logs = deps.calls.log.mock.calls.map((call) => String(call[0])).join("\n"); + expect(logs).toContain("omitting context_length so Hermes can discover it"); }); it("re-seeds the isolated Hermes dashboard config after an in-place switch (#6893)", async () => { @@ -358,12 +420,13 @@ describe("runInferenceSet Hermes routing", () => { default: "claude-sonnet-proxy", provider: "custom", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, }); // The upstream annotation must track the selected provider together with // the API-family field, so the two cannot drift apart on later switches. expect(config._nemoclaw_upstream).toEqual({ provider: "compatible-anthropic-endpoint", + provider_key: "compatible-anthropic-endpoint", model: "claude-sonnet-proxy", }); expect(deps.calls.updateSandbox.mock.calls.at(-1)).toEqual([ @@ -515,7 +578,7 @@ describe("runInferenceSet Hermes routing", () => { default: "anthropic.claude-sonnet-4-6-20260101-v1:0", provider: "custom", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, }); expect(result).toMatchObject({ providerKey: "inference", diff --git a/src/lib/actions/inference-set-patch-hermes.test.ts b/src/lib/actions/inference-set-patch-hermes.test.ts index 41cfdf5d27e..cbc3eb94af9 100644 --- a/src/lib/actions/inference-set-patch-hermes.test.ts +++ b/src/lib/actions/inference-set-patch-hermes.test.ts @@ -2,17 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; -import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../hermes-proxy-api-key"; +import { HERMES_PROXY_REWRITE_SENTINEL } from "../hermes-managed-route"; import type { ConfigObject } from "../security/credential-filter"; import { patchHermesInferenceConfig } from "./inference-set"; describe("patchHermesInferenceConfig", () => { - it("updates only the Hermes model block for the selected route", () => { + it("updates the complete Hermes route for the selected provider", () => { const config: ConfigObject = { model: { default: "moonshotai/kimi-k2.6", provider: "custom", base_url: "https://old.example/v1", + context_length: 32_768, temperature: 0.2, }, models: { @@ -32,9 +33,30 @@ describe("patchHermesInferenceConfig", () => { default: "openai/gpt-5.4-mini", provider: "custom", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, - temperature: 0.2, + api_key: HERMES_PROXY_REWRITE_SENTINEL, }); + expect(config._nemoclaw_upstream).toEqual({ + provider: "hermes-provider", + provider_key: "hermes-provider", + model: "openai/gpt-5.4-mini", + }); + expect(config.providers).toEqual({ + "hermes-provider": { + name: "hermes-provider", + api: "https://inference.local/v1", + api_key: HERMES_PROXY_REWRITE_SENTINEL, + default_model: "openai/gpt-5.4-mini", + discover_models: true, + }, + }); + expect(config.custom_providers).toEqual([ + { + name: "hermes-provider", + base_url: "https://inference.local/v1", + api_key: HERMES_PROXY_REWRITE_SENTINEL, + discover_models: true, + }, + ]); expect(config.models).toEqual({ providers: { inference: { @@ -45,7 +67,27 @@ describe("patchHermesInferenceConfig", () => { expect(config.terminal).toEqual({ backend: "local" }); }); - it("replaces stale Hermes API keys with the OpenShell proxy placeholder", () => { + it("writes the selected Hermes model context window instead of retaining the previous route", () => { + const config: ConfigObject = { + model: { + default: "old-model", + provider: "custom", + base_url: "https://old.example/v1", + context_length: 32_768, + }, + }; + + patchHermesInferenceConfig(config, "hermes-provider", "openai/gpt-5.4-mini", null, 128_000); + + expect(config.model).toEqual( + expect.objectContaining({ + default: "openai/gpt-5.4-mini", + context_length: 128_000, + }), + ); + }); + + it("replaces stale Hermes API keys with the OpenShell proxy rewrite sentinel", () => { for (const api_key of ["no-key-required", "sk-real-looking-key-that-must-not-survive"]) { const config: ConfigObject = { model: { @@ -58,7 +100,7 @@ describe("patchHermesInferenceConfig", () => { patchHermesInferenceConfig(config, "hermes-provider", "openai/gpt-5.4-mini"); - expect((config.model as ConfigObject).api_key).toBe(HERMES_PROXY_API_KEY_PLACEHOLDER); + expect((config.model as ConfigObject).api_key).toBe(HERMES_PROXY_REWRITE_SENTINEL); } }); @@ -83,7 +125,7 @@ describe("patchHermesInferenceConfig", () => { default: "claude-sonnet-4-6", provider: "custom", base_url: "https://inference.local", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, api_mode: "anthropic_messages", }); }); @@ -104,7 +146,7 @@ describe("patchHermesInferenceConfig", () => { default: "nvidia/nemotron-3-super-120b-a12b", provider: "custom", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, }); }); @@ -128,7 +170,7 @@ describe("patchHermesInferenceConfig", () => { default: "anthropic.claude-3-5-sonnet-20240620-v1:0", provider: "custom", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, }); }); }); diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 42b194f9db4..72fa18fb767 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -5,7 +5,7 @@ import type { CaptureOpenshellOptions, CaptureOpenshellResult } from "../adapter import { captureOpenshell, getOpenshellBinary } from "../adapters/openshell/runtime"; import { CLI_NAME } from "../cli/branding"; import { shellQuote } from "../core/shell-quote"; -import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../hermes-proxy-api-key"; +import { applyHermesManagedRoute } from "../hermes-managed-route"; import { isBedrockRuntimeEndpoint } from "../inference/bedrock-runtime"; import { getProviderSelectionConfig, @@ -56,7 +56,7 @@ import * as onboardSession from "../state/onboard-session"; import type { SandboxEntry } from "../state/registry"; import * as registry from "../state/registry"; import { isSafeModelId } from "../validation"; -import { hermesApiMode, resolveRuntimeInferenceApi } from "./inference-route-api"; +import { resolveRuntimeInferenceApi } from "./inference-route-api"; import { InferenceSetError, OPEN_SHELL_FAILURE_CAPTURE_MAX_BUFFER, @@ -556,27 +556,38 @@ export function patchHermesInferenceConfig( provider: string, model: string, preferredInferenceApi: string | null = null, + contextWindow?: number, ): { changed: boolean; route: SandboxInferenceConfig } { const before = JSON.stringify(config); const route = getSandboxInferenceConfig(model, provider, preferredInferenceApi); - const upstream = ensureObject(config, "_nemoclaw_upstream"); - upstream.provider = provider; - upstream.model = model; - const modelConfig = ensureObject(config, "model"); - modelConfig.default = model; - modelConfig.base_url = route.inferenceBaseUrl; - modelConfig.provider = "custom"; - modelConfig.api_key = HERMES_PROXY_API_KEY_PLACEHOLDER; - const apiMode = hermesApiMode(route.inferenceApi); - if (apiMode) { - modelConfig.api_mode = apiMode; - } else { - delete modelConfig.api_mode; - } + applyHermesManagedRoute(config, { + model, + baseUrl: route.inferenceBaseUrl, + upstreamProvider: provider, + inferenceApi: route.inferenceApi, + contextWindow, + }); return { changed: before !== JSON.stringify(config), route }; } +function resolveHermesContextWindowForSwitch( + provider: string, + model: string, + deps: Pick, +): number | undefined { + const contextWindow = deps.resolveContextWindowForModel(provider, model); + if (contextWindow != null) { + deps.log(` Context window for '${model}': ${contextWindow} tokens`); + return contextWindow; + } + deps.log( + ` Warning: could not determine the context window for '${model}'; omitting ` + + `context_length so Hermes can discover it from the selected model.`, + ); + return undefined; +} + function updateMatchingOnboardSession( sandboxName: string, provider: string, @@ -1185,7 +1196,14 @@ async function runInferenceSetWithoutHostLock( let patched: { changed: boolean; route: SandboxInferenceConfig }; if (agentName === "hermes") { - patched = patchHermesInferenceConfig(config, provider, model, preferredInferenceApi); + const contextWindow = resolveHermesContextWindowForSwitch(provider, model, deps); + patched = patchHermesInferenceConfig( + config, + provider, + model, + preferredInferenceApi, + contextWindow, + ); } else { // Recompute the context window for the model being switched to, so it does // not inherit the prior model's window (#context-window-on-switch). diff --git a/src/lib/hermes-managed-route.ts b/src/lib/hermes-managed-route.ts new file mode 100644 index 00000000000..30db5326d5c --- /dev/null +++ b/src/lib/hermes-managed-route.ts @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Hermes requires an sk-prefixed value before it sends a request. OpenShell +// removes this non-secret sentinel and injects the route credential at egress. +export const HERMES_PROXY_REWRITE_SENTINEL = "sk-OPENSHELL-PROXY-REWRITE"; + +type HermesManagedProvider = { + name: string; + api_key: typeof HERMES_PROXY_REWRITE_SENTINEL; + discover_models: true; + api?: string; + base_url?: string; + default_model?: string; + transport?: string; + api_mode?: string; +}; + +export type HermesManagedRouting = { + _nemoclaw_upstream: { + provider: string; + provider_key: string; + model: string; + }; + model: { + default: string; + provider: "custom"; + base_url: string; + api_key: typeof HERMES_PROXY_REWRITE_SENTINEL; + api_mode?: string; + context_length?: number; + }; + providers: Record; + custom_providers: HermesManagedProvider[]; +}; + +export type HermesManagedRoute = { + model: string; + baseUrl: string; + upstreamProvider: string; + inferenceApi: string; + contextWindow?: number | null; +}; + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function hermesApiMode(inferenceApi: string): string | null { + 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 hermesProviderKey(provider: string): string { + const normalized = provider + .trim() + .toLowerCase() + .replaceAll(" ", "-") + .replace(/[()]/gu, "") + .replace(/-+/gu, "-") + .replace(/^-|-$/gu, ""); + return normalized || "nemoclaw-inference"; +} + +/** Apply the complete NemoClaw-owned Hermes route to an existing config. */ +export function applyHermesManagedRoute( + config: Record, + route: HermesManagedRoute, +): asserts config is Record & HermesManagedRouting { + const providerName = route.upstreamProvider || "nemoclaw-inference"; + const providerKey = hermesProviderKey(providerName); + const apiMode = hermesApiMode(route.inferenceApi); + const previousUpstream = isObjectRecord(config._nemoclaw_upstream) + ? config._nemoclaw_upstream + : {}; + const previousProviderKey = + typeof previousUpstream.provider_key === "string" ? previousUpstream.provider_key : ""; + + const modelConfig: Record = { + default: route.model, + provider: "custom", + base_url: route.baseUrl, + api_key: HERMES_PROXY_REWRITE_SENTINEL, + }; + if (apiMode) modelConfig.api_mode = apiMode; + if (route.contextWindow !== null && route.contextWindow !== undefined) { + // Hermes reads context_length before endpoint discovery and model metadata. + modelConfig.context_length = route.contextWindow; + } + + const providerConfig: Record = { + name: providerName, + api: route.baseUrl, + api_key: HERMES_PROXY_REWRITE_SENTINEL, + default_model: route.model, + discover_models: true, + }; + if (apiMode) providerConfig.transport = apiMode; + + const customProvider: Record = { + name: providerName, + base_url: route.baseUrl, + api_key: HERMES_PROXY_REWRITE_SENTINEL, + discover_models: true, + }; + if (apiMode) customProvider.api_mode = apiMode; + + const providers = isObjectRecord(config.providers) ? { ...config.providers } : {}; + if (previousProviderKey && previousProviderKey !== providerKey) { + delete providers[previousProviderKey]; + } + providers[providerKey] = providerConfig; + + const customProviders = Array.isArray(config.custom_providers) + ? config.custom_providers.filter( + (entry) => + !isObjectRecord(entry) || + (entry.name !== previousUpstream.provider && entry.name !== providerName), + ) + : []; + customProviders.push(customProvider); + + config._nemoclaw_upstream = { + provider: providerName, + provider_key: providerKey, + model: route.model, + }; + config.model = modelConfig; + config.providers = providers; + config.custom_providers = customProviders; +} diff --git a/src/lib/hermes-proxy-api-key.ts b/src/lib/hermes-proxy-api-key.ts deleted file mode 100644 index 1d4449ce359..00000000000 --- a/src/lib/hermes-proxy-api-key.ts +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Non-secret OpenShell proxy rewrite sentinel shared by Hermes config paths. -// Hermes/LiteLLM requires an `sk-`-prefixed value before it will issue a -// request, but OpenShell strips this placeholder and injects the real route -// credential at the egress boundary. Remove this once Hermes no longer gates -// custom endpoints on a credential-shaped API key. -export const HERMES_PROXY_API_KEY_PLACEHOLDER = "sk-OPENSHELL-PROXY-REWRITE"; diff --git a/src/lib/onboard/managed-startup-image-runtime.test.ts b/src/lib/onboard/managed-startup-image-runtime.test.ts index 22a47e04ee9..ebf78276919 100644 --- a/src/lib/onboard/managed-startup-image-runtime.test.ts +++ b/src/lib/onboard/managed-startup-image-runtime.test.ts @@ -36,6 +36,7 @@ import { applyManagedStartupImageProfile, applyManagedStartupRootRequest, buildManagedStartupImageActionPlan, + installHermesManagedPolicy, MANAGED_STARTUP_PROFILE_ENV, type ManagedStartupImageActionPlanInput, } from "./managed-startup/image-runtime"; @@ -327,11 +328,86 @@ describe("buildManagedStartupImageActionPlan", () => { }); describe("managed startup image runtime", () => { + const policyTemporaryDirectories: string[] = []; + + function temporaryPolicyDirectory(): string { + const directory = fs.mkdtempSync(path.join(process.env.TMPDIR!, "nemoclaw-managed-policy-")); + policyTemporaryDirectories.push(directory); + return directory; + } + beforeEach(() => { coordinatorMock.coordinateManagedStartupApplication.mockReset(); }); afterEach(() => { vi.restoreAllMocks(); + for (const directory of policyTemporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + function mockRootOwnedPolicyInstallPaths( + source: string, + shareDirectory: string, + target: string, + beforeSourceCleanup?: () => void, + ): void { + const realLstatSync = fs.lstatSync.bind(fs); + const rootOwned = (stat: fs.Stats): fs.Stats => + new Proxy(stat, { + get(inner, property) { + const value = property === "uid" || property === "gid" ? 0 : Reflect.get(inner, property); + return typeof value === "function" ? value.bind(inner) : value; + }, + }); + vi.spyOn(fs, "lstatSync").mockImplementation((( + file: fs.PathLike, + options?: { bigint?: boolean }, + ) => { + const sourceCleanupHook = + file.toString() === source && options?.bigint === true ? beforeSourceCleanup : undefined; + sourceCleanupHook?.(); + const stat = options?.bigint ? realLstatSync(file, { bigint: true }) : realLstatSync(file); + const rootPath = file.toString() === shareDirectory || file.toString() === target; + return rootPath && options?.bigint !== true ? rootOwned(stat as fs.Stats) : stat; + }) as typeof fs.lstatSync); + vi.spyOn(fs, "fchownSync").mockImplementation(() => undefined); + } + + it("promotes generated Hermes policy to one root-owned, read-only runtime artifact", () => { + const directory = temporaryPolicyDirectory(); + const shareDirectory = path.join(directory, "share"); + const source = path.join(directory, "managed-policy.json"); + const target = path.join(shareDirectory, "hermes-managed-policy.json"); + const policy = '{"schema_version":1}\n'; + fs.mkdirSync(shareDirectory); + fs.writeFileSync(source, policy, { mode: 0o600 }); + mockRootOwnedPolicyInstallPaths(source, shareDirectory, target); + + installHermesManagedPolicy(source, target); + + expect(fs.existsSync(source)).toBe(false); + expect(fs.readFileSync(target, "utf8")).toBe(policy); + expect(fs.statSync(target).mode & 0o777).toBe(0o444); + }); + + it("preserves generated Hermes policy when it changes before source cleanup", () => { + const directory = temporaryPolicyDirectory(); + const shareDirectory = path.join(directory, "share"); + const source = path.join(directory, "managed-policy.json"); + const target = path.join(shareDirectory, "hermes-managed-policy.json"); + const policy = '{"schema_version":1}\n'; + fs.mkdirSync(shareDirectory); + fs.writeFileSync(source, policy, { mode: 0o600 }); + mockRootOwnedPolicyInstallPaths(source, shareDirectory, target, () => { + fs.appendFileSync(source, "changed\n"); + }); + + expect(() => installHermesManagedPolicy(source, target)).toThrow( + /changed before source cleanup/u, + ); + expect(fs.readFileSync(source, "utf8")).toBe(`${policy}changed\n`); + expect(fs.readFileSync(target, "utf8")).toBe(policy); }); it("rejects invalid OpenClaw launch controls before filesystem or coordinator mutation", async () => { diff --git a/src/lib/onboard/managed-startup/image-runtime.ts b/src/lib/onboard/managed-startup/image-runtime.ts index 8e219477cd8..a79e7ca044d 100644 --- a/src/lib/onboard/managed-startup/image-runtime.ts +++ b/src/lib/onboard/managed-startup/image-runtime.ts @@ -60,6 +60,9 @@ const HERMES_MANAGED_CONFIG_FILES = [ "/sandbox/.hermes/config.yaml", "/sandbox/.hermes/.env", ] as const; +const HERMES_GENERATED_MANAGED_POLICY_FILE = "/sandbox/.hermes/managed-policy.json"; +const HERMES_INSTALLED_MANAGED_POLICY_FILE = "/usr/local/share/nemoclaw/hermes-managed-policy.json"; +const MAX_HERMES_MANAGED_POLICY_BYTES = 4 * 1024 * 1024; const FIXED_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; const SHA256_RE = /^[a-f0-9]{64}$/u; export const MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION = 1; @@ -816,6 +819,20 @@ export function readStableRegularFile(target: string, maxBytes: number): Buffer return readStableRegularFileSnapshot(target, maxBytes).bytes; } +/** Promote the generator output to the one root-owned policy artifact used at runtime. */ +export function installHermesManagedPolicy( + source = HERMES_GENERATED_MANAGED_POLICY_FILE, + target = HERMES_INSTALLED_MANAGED_POLICY_FILE, +): void { + const generated = readStableRegularFileSnapshot(source, MAX_HERMES_MANAGED_POLICY_BYTES); + atomicWriteRootFile(target, generated.bytes, 0o444); + const current = fs.lstatSync(source, { bigint: true }); + if (!sameStableFileMetadata(generated.stat, current)) { + fail(`Hermes managed policy changed before source cleanup: ${source}`); + } + fs.unlinkSync(source); +} + /** * Restore the mutable Hermes image contract after its sandbox-side generator * atomically replaces config.yaml or .env with mode 0600. The mode transition @@ -1296,6 +1313,7 @@ function applyAdapter( sealOpenClawConfiguration(mapped.configurationEnvironment, mapped.applicationRuntime); break; case "hermes": + installHermesManagedPolicy(); sealHermesConfiguration(mapped.configurationEnvironment, mapped.applicationRuntime); // Normalize before the coordinator commits a newly applied profile so // the durable transaction never records generator-created 0600 files as diff --git a/src/lib/sandbox/config.ts b/src/lib/sandbox/config.ts index dbe33004972..f342964d077 100644 --- a/src/lib/sandbox/config.ts +++ b/src/lib/sandbox/config.ts @@ -549,6 +549,7 @@ function writeSandboxConfig( if (result.issues.length > 0) { configFail(result.issues.map((issue) => ` ${issue}`)); } + // Integrity-only digest for guard output; this is not a password verifier. const expectedNewDigest = createHash("sha256").update(content).digest("hex"); if (result.configSha256 !== expectedNewDigest) { throw new Error( @@ -607,6 +608,7 @@ function recomputeSandboxConfigHash(sandboxName: string, target: AgentConfigTarg // (installed by the agents/hermes image build). The python resolution order // mirrors start.sh's trusted `_HERMES_PYTHON` list. const HERMES_DASHBOARD_SEEDER_PATH = "/usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py"; +const HERMES_MANAGED_POLICY_PATH = "/usr/local/share/nemoclaw/hermes-managed-policy.json"; const HERMES_TRUSTED_PYTHON3 = [ "/opt/hermes/.venv/bin/python3", "/usr/local/bin/python3", @@ -754,6 +756,7 @@ function runHermesDashboardConfigSeed( python, HERMES_DASHBOARD_SEEDER_PATH, ...(mergeLegacy ? ["--merge-legacy"] : []), + HERMES_MANAGED_POLICY_PATH, target.configPath, dashboardConfigPath, `${target.configDir}/.env`, diff --git a/src/lib/sandbox/hermes-dashboard-reseed.test.ts b/src/lib/sandbox/hermes-dashboard-reseed.test.ts index afdad6c7fe5..2b4731a6dd9 100644 --- a/src/lib/sandbox/hermes-dashboard-reseed.test.ts +++ b/src/lib/sandbox/hermes-dashboard-reseed.test.ts @@ -24,6 +24,7 @@ const TARGET: AgentConfigTarget = { }; const PYTHON = "/opt/hermes/.venv/bin/python3"; const SEEDER = "/usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py"; +const POLICY = "/usr/local/share/nemoclaw/hermes-managed-policy.json"; const DASHBOARD_CONFIG = "/sandbox/.hermes/profiles/dashboard-home/config.yaml"; const capture = vi.fn<(binary: string, args: string[], options: unknown) => CaptureResult>(); const reportFailure = vi.fn<(stage: "python" | "inspection" | "seed", detail: string) => void>(); @@ -111,6 +112,7 @@ describe("seedHermesDashboardConfig", () => { expect(sandboxCommand(capture.mock.calls[2][1])).toEqual([ PYTHON, SEEDER, + POLICY, "/sandbox/Hermes config;$(touch source-pwned)/config'quote.yaml", "/sandbox/Hermes home;$(touch dir-pwned)/profiles/dashboard-home/config.yaml", "/sandbox/Hermes home;$(touch dir-pwned)/.env", @@ -153,6 +155,7 @@ describe("seedHermesDashboardConfig", () => { PYTHON, SEEDER, "--merge-legacy", + POLICY, TARGET.configPath, DASHBOARD_CONFIG, "/sandbox/.hermes/.env", diff --git a/test/e2e/live/mcp-bridge-cleanup.ts b/test/e2e/live/mcp-bridge-cleanup.ts new file mode 100644 index 00000000000..82f8b36471a --- /dev/null +++ b/test/e2e/live/mcp-bridge-cleanup.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import { assertCleanupSucceededOrAbsent } from "../fixtures/cleanup-resources.ts"; +import { resultText } from "../fixtures/clients/command.ts"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; + +export type McpAdapter = "mcporter" | "hermes-config" | "deepagents-config"; + +export const MCP_MUTATION_TIMEOUT_MS: Record = { + "deepagents-config": 3 * 60_000, + "hermes-config": 12 * 60_000, + mcporter: 3 * 60_000, +}; + +const MCP_BRIDGE_ALREADY_ABSENT = + /No MCP servers are registered|No MCP server '.+' is registered|MCP server '.+' not found/iu; + +export async function cleanupMcpBridge( + host: HostCliClient, + sandboxName: string, + server: string, + adapter: McpAdapter, +): Promise { + const result = await host.nemoclaw([sandboxName, "mcp", "remove", server, "--force"], { + artifactName: `cleanup-mcp-remove-${server}`, + env: buildAvailabilityProbeEnv(), + timeoutMs: MCP_MUTATION_TIMEOUT_MS[adapter], + }); + assertCleanupSucceededOrAbsent( + result, + MCP_BRIDGE_ALREADY_ABSENT, + `cleanup MCP bridge ${server} on sandbox ${sandboxName}`, + ); +} + +const MCP_MUTATION_CONCURRENCY_CONFLICT = + /sandbox was modified by another operation\.[\s\S]*Please retry the command\./iu; + +export function shouldRetryMcpMutationAfterConcurrencyConflict(output: string): boolean { + return MCP_MUTATION_CONCURRENCY_CONFLICT.test(output); +} + +export async function removeMcpBridgeWithOneConcurrencyRetry( + host: HostCliClient, + sandboxName: string, + server: string, + adapter: McpAdapter, + artifactPrefix: string, +): Promise>> { + const remove = await host.nemoclaw([sandboxName, "mcp", "remove", server], { + artifactName: `${artifactPrefix}-mcp-remove-${server}`, + env: buildAvailabilityProbeEnv(), + timeoutMs: MCP_MUTATION_TIMEOUT_MS[adapter], + }); + if ( + remove.exitCode === 0 || + !shouldRetryMcpMutationAfterConcurrencyConflict(resultText(remove)) + ) { + return remove; + } + return host.nemoclaw([sandboxName, "mcp", "remove", server], { + artifactName: `${artifactPrefix}-mcp-remove-${server}-retry`, + env: buildAvailabilityProbeEnv(), + timeoutMs: MCP_MUTATION_TIMEOUT_MS[adapter], + }); +} diff --git a/test/e2e/live/mcp-bridge-tool-discovery.ts b/test/e2e/live/mcp-bridge-tool-discovery.ts index 965735b1c5e..a64fc92447d 100644 --- a/test/e2e/live/mcp-bridge-tool-discovery.ts +++ b/test/e2e/live/mcp-bridge-tool-discovery.ts @@ -32,6 +32,12 @@ export function shouldRetryMcpToolDiscoveryTransportFailure( ); } +export function shouldRetryMcpDiscoveryAfterRestart( + requestsSinceAttempt: readonly FakeMcpRequest[], +): boolean { + return requestsSinceAttempt.length === 0; +} + type McpToolDiscoveryStatusJson = { provider: { credentialResolution?: unknown }; toolDiscovery: { @@ -132,6 +138,38 @@ export async function assertAuthenticatedMcpDiscovery( .toMatchObject({ discovered: true }); } +type AuthenticatedMcpDiscoveryRestartDeps = { + assertDiscovery: typeof assertAuthenticatedMcpDiscovery; +}; + +const AUTHENTICATED_MCP_DISCOVERY_RESTART_DEPS: AuthenticatedMcpDiscoveryRestartDeps = { + assertDiscovery: assertAuthenticatedMcpDiscovery, +}; + +export async function assertAuthenticatedMcpDiscoveryWithOneRestart( + fakeMcp: FakeMcpHttpsServer, + options: { + requestOffset: number; + expectedSecret: string; + label: string; + restart: () => Promise; + }, + deps: AuthenticatedMcpDiscoveryRestartDeps = AUTHENTICATED_MCP_DISCOVERY_RESTART_DEPS, +): Promise { + try { + await deps.assertDiscovery(fakeMcp, options); + } catch (error) { + if (!shouldRetryMcpDiscoveryAfterRestart(fakeMcp.requests.slice(options.requestOffset))) { + throw error; + } + await options.restart(); + await deps.assertDiscovery(fakeMcp, { + ...options, + label: `${options.label} after one bridge restart`, + }); + } +} + export async function assertAuthenticatedMcpToolDiscovery( host: HostCliClient, fakeMcp: FakeMcpHttpsServer, diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index 84789a9161e..664352a26ef 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -13,7 +13,6 @@ import { shellQuote } from "../../../src/lib/core/shell-quote"; import type { McpBridgeEntry } from "../../../src/lib/state/registry"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import type { CleanupRegistry } from "../fixtures/cleanup.ts"; -import { assertCleanupSucceededOrAbsent } from "../fixtures/cleanup-resources.ts"; import { assertExitZero as expectExitZero, resultText } from "../fixtures/clients/command.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; import { type SandboxClient, trustedSandboxShellScript } from "../fixtures/clients/sandbox.ts"; @@ -21,6 +20,12 @@ import { test as e2eTest, expect } from "../fixtures/e2e-test.ts"; import { MCP_BRIDGE_TEST_CREDENTIALS } from "../fixtures/mcp-bridge-credentials.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { type McpBridgeShard, resolveMcpBridgeShard } from "./mcp-bridge-agent-selection.ts"; +import { + cleanupMcpBridge, + MCP_MUTATION_TIMEOUT_MS, + type McpAdapter, + removeMcpBridgeWithOneConcurrencyRetry, +} from "./mcp-bridge-cleanup.ts"; import { assertHermesConfig, assertHermesInspectionRejectsUnmanagedFields, @@ -51,6 +56,7 @@ import { } from "./mcp-bridge-servers.ts"; import { assertAuthenticatedMcpDiscovery, + assertAuthenticatedMcpDiscoveryWithOneRestart, assertAuthenticatedMcpRediscovery, assertAuthenticatedMcpToolDiscovery, } from "./mcp-bridge-tool-discovery.ts"; @@ -82,31 +88,6 @@ function mcpBridgeShardTest(shard: McpBridgeShard) { } const test = mcpBridgeShardTest("openclaw"); type McpAgent = "openclaw" | "hermes" | "langchain-deepagents-code"; -type McpAdapter = "mcporter" | "hermes-config" | "deepagents-config"; -const MCP_MUTATION_TIMEOUT_MS: Record = { - "deepagents-config": 3 * 60_000, - "hermes-config": 12 * 60_000, - mcporter: 3 * 60_000, -}; -const MCP_BRIDGE_ALREADY_ABSENT = - /No MCP servers are registered|No MCP server '.+' is registered|MCP server '.+' not found/iu; -async function cleanupMcpBridge( - host: HostCliClient, - sandboxName: string, - server: string, - adapter: McpAdapter, -): Promise { - const result = await host.nemoclaw([sandboxName, "mcp", "remove", server, "--force"], { - artifactName: `cleanup-mcp-remove-${server}`, - env: buildAvailabilityProbeEnv(), - timeoutMs: MCP_MUTATION_TIMEOUT_MS[adapter], - }); - assertCleanupSucceededOrAbsent( - result, - MCP_BRIDGE_ALREADY_ABSENT, - `cleanup MCP bridge ${server} on sandbox ${sandboxName}`, - ); -} async function onboardAgent( host: HostCliClient, cleanup: CleanupRegistry, @@ -573,11 +554,13 @@ async function removeBridgeAndAssertEmpty( mcpUrl: string; }, ): Promise { - const remove = await host.nemoclaw([options.sandboxName, "mcp", "remove", SERVER_NAME], { - artifactName: `${options.artifactPrefix}-mcp-remove-fake-server`, - env: buildAvailabilityProbeEnv(), - timeoutMs: MCP_MUTATION_TIMEOUT_MS[options.adapter], - }); + const remove = await removeMcpBridgeWithOneConcurrencyRetry( + host, + options.sandboxName, + SERVER_NAME, + options.adapter, + options.artifactPrefix, + ); expectExitZero(remove, `${options.artifactPrefix} mcp remove fake server`); const list = await host.nemoclaw([options.sandboxName, "mcp", "list", "--json"], { artifactName: `${options.artifactPrefix}-mcp-list-after-remove`, @@ -1212,10 +1195,14 @@ mcpBridgeShardTest("hermes")( expectedAdapter: "hermes-config", artifactPrefix: "hermes", }); - await assertAuthenticatedMcpDiscovery(fakeMcp, { + await assertAuthenticatedMcpDiscoveryWithOneRestart(fakeMcp, { requestOffset: initialDiscoveryOffset, expectedSecret: HOST_SECRET, label: "Hermes initial MCP discovery", + restart: async () => { + progress.event("Hermes MCP discovery did not reach the fixture; restarting once"); + await restartBridgeWithoutHostSecret(host, HERMES_SANDBOX_NAME, "hermes-discovery-retry"); + }, }); await assertAuthenticatedMcpToolDiscovery(host, fakeMcp, { sandboxName: HERMES_SANDBOX_NAME, diff --git a/test/e2e/support/mcp-bridge-tool-discovery.test.ts b/test/e2e/support/mcp-bridge-tool-discovery.test.ts index 67dc51fdbde..45818a6eebc 100644 --- a/test/e2e/support/mcp-bridge-tool-discovery.test.ts +++ b/test/e2e/support/mcp-bridge-tool-discovery.test.ts @@ -1,16 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, describe, expect, it } from "vitest"; - +import { afterEach, describe, expect, it, vi } from "vitest"; +import { shouldRetryMcpMutationAfterConcurrencyConflict } from "../live/mcp-bridge-cleanup.ts"; import { + type FakeMcpHttpsServer, type FakeMcpRequest, HERMES_DEFERRED_TOOL_SEARCH_MISS, type StartedHttpServer, startCompatibleMock, } from "../live/mcp-bridge-servers.ts"; import { + assertAuthenticatedMcpDiscoveryWithOneRestart, hasSuccessfulAuthenticatedMcpDiscovery, + shouldRetryMcpDiscoveryAfterRestart, shouldRetryMcpToolDiscoveryTransportFailure, } from "../live/mcp-bridge-tool-discovery.ts"; @@ -216,6 +219,91 @@ describe("authenticated MCP tool discovery transport retry", () => { }); }); +describe("authenticated MCP discovery restart retry", () => { + it("retries when no request reached the fixture", () => { + expect(shouldRetryMcpDiscoveryAfterRestart([])).toBe(true); + }); + + it("does not retry after the fixture received a request", () => { + expect(shouldRetryMcpDiscoveryAfterRestart([request("initialize")])).toBe(false); + }); + + it("restarts once and retries discovery when no request reached the fixture", async () => { + const fakeMcp = { requests: [] } as unknown as FakeMcpHttpsServer; + const assertDiscovery = vi + .fn() + .mockRejectedValueOnce(new Error("first discovery failed")) + .mockResolvedValueOnce(undefined); + const restart = vi.fn().mockResolvedValueOnce(undefined); + + await assertAuthenticatedMcpDiscoveryWithOneRestart( + fakeMcp, + { + requestOffset: 0, + expectedSecret: EXPECTED_SECRET, + label: "initial discovery", + restart, + }, + { assertDiscovery }, + ); + + expect(restart).toHaveBeenCalledOnce(); + expect(assertDiscovery).toHaveBeenCalledTimes(2); + expect(assertDiscovery.mock.calls[1]?.[1]).toMatchObject({ + label: "initial discovery after one bridge restart", + }); + }); + + it("does not restart when the failed attempt reached the fixture", async () => { + const fakeMcp = { requests: [request("initialize")] } as unknown as FakeMcpHttpsServer; + const failure = new Error("fixture-visible discovery failed"); + const assertDiscovery = vi.fn().mockRejectedValueOnce(failure); + const restart = vi.fn().mockResolvedValueOnce(undefined); + + await expect( + assertAuthenticatedMcpDiscoveryWithOneRestart( + fakeMcp, + { + requestOffset: 0, + expectedSecret: EXPECTED_SECRET, + label: "initial discovery", + restart, + }, + { assertDiscovery }, + ), + ).rejects.toBe(failure); + + expect(restart).not.toHaveBeenCalled(); + expect(assertDiscovery).toHaveBeenCalledOnce(); + }); + + it("propagates the retry failure without a second restart", async () => { + const fakeMcp = { requests: [] } as unknown as FakeMcpHttpsServer; + const retryFailure = new Error("retry discovery failed"); + const assertDiscovery = vi + .fn() + .mockRejectedValueOnce(new Error("first discovery failed")) + .mockRejectedValueOnce(retryFailure); + const restart = vi.fn().mockResolvedValueOnce(undefined); + + await expect( + assertAuthenticatedMcpDiscoveryWithOneRestart( + fakeMcp, + { + requestOffset: 0, + expectedSecret: EXPECTED_SECRET, + label: "initial discovery", + restart, + }, + { assertDiscovery }, + ), + ).rejects.toBe(retryFailure); + + expect(restart).toHaveBeenCalledOnce(); + expect(assertDiscovery).toHaveBeenCalledTimes(2); + }); +}); + describe("Hermes deferred MCP tool discovery", () => { it("uses one tool_search, tool_describe, and tool_call when the deferred target is present", async () => { compatibleMock = await startDeferredCompatibleMock(); @@ -294,3 +382,21 @@ describe("Hermes deferred MCP tool discovery", () => { expect(terminalMessage.tool_calls).toBeUndefined(); }); }); + +describe("MCP mutation concurrency retry", () => { + it("retries the explicit OpenShell optimistic-concurrency response", () => { + expect( + shouldRetryMcpMutationAfterConcurrencyConflict( + "Failed to detach provider: sandbox was modified by another operation.\nPlease retry the command.", + ), + ).toBe(true); + }); + + it.each([ + "Failed to detach provider: permission denied", + "sandbox was modified by another operation.", + "Please retry the command.", + ])("does not retry another failure: %s", (output) => { + expect(shouldRetryMcpMutationAfterConcurrencyConflict(output)).toBe(false); + }); +}); diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 5a913d7dd4a..e0c164a36d7 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -12,12 +12,10 @@ import { readHermesBuildSettings, } from "../agents/hermes/config/build-env.ts"; import { generateHermesConfig } from "../agents/hermes/config/generate.ts"; -import { - buildHermesConfig, - MANAGED_IMAGE_HERMES_NEUTRAL_PLATFORMS, -} from "../agents/hermes/config/hermes-config.ts"; +import { buildHermesManagedPolicy } from "../agents/hermes/config/managed-policy.ts"; +import { MANAGED_IMAGE_HERMES_NEUTRAL_PLATFORMS } from "../agents/hermes/config/managed-policy.ts"; import { discoverModelSpecificSetups } from "../agents/hermes/config/model-specific-setup.ts"; -import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../src/lib/hermes-proxy-api-key"; +import { HERMES_PROXY_REWRITE_SENTINEL } from "../src/lib/hermes-managed-route"; import { applyCompatibleEndpointContextWindow, resetCompatibleEndpointContextWindowAutoState, @@ -242,6 +240,10 @@ function copyConfigGeneratorFixture(fixtureRoot: string): string { path.join(import.meta.dirname, "..", "src", "lib", "tool-disclosure.ts"), path.join(fixtureRoot, "src", "lib", "tool-disclosure.ts"), ); + fs.copyFileSync( + path.join(import.meta.dirname, "..", "src", "lib", "hermes-managed-route.ts"), + path.join(fixtureRoot, "src", "lib", "hermes-managed-route.ts"), + ); return fixtureScriptPath; } @@ -405,7 +407,10 @@ describe("agents/hermes/generate-config.ts", () => { notify_exclude_platforms: ["api_server", "webhook"], bg_process_max_age_hours: 24, }); - expect(config.browser).toEqual({ restrict_evaluate: true }); + expect(config.browser).toEqual({ + allow_unsafe_evaluate: false, + restrict_evaluate: true, + }); expect(config.display).toMatchObject({ compact: false, tool_progress: "all", @@ -443,7 +448,7 @@ describe("agents/hermes/generate-config.ts", () => { default: "test-model", provider: "custom", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, }); expect(config.platforms).toEqual({ api_server: { @@ -508,6 +513,7 @@ describe("agents/hermes/generate-config.ts", () => { expect(config._nemoclaw_upstream).toEqual({ provider: "nvidia-prod", + provider_key: "nvidia-prod", model: "nvidia/nemotron-3-super-120b-a12b", }); }); @@ -525,7 +531,7 @@ describe("agents/hermes/generate-config.ts", () => { "nvidia-prod": { name: "nvidia-prod", api: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, default_model: "nvidia/nemotron-3-super-120b-a12b", discover_models: true, }, @@ -534,7 +540,7 @@ describe("agents/hermes/generate-config.ts", () => { { name: "nvidia-prod", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, discover_models: true, }, ]); @@ -555,7 +561,7 @@ describe("agents/hermes/generate-config.ts", () => { const settings = readHermesBuildSettings( buildHermesTestEnv({ NEMOCLAW_CONTEXT_WINDOW: String(MIN_HERMES_CONTEXT_WINDOW) }), ); - const config = buildHermesConfig(settings); + const config = buildHermesManagedPolicy(settings).config; expect((config.model as Record).context_length).toBe( MIN_HERMES_CONTEXT_WINDOW, @@ -676,7 +682,7 @@ describe("agents/hermes/generate-config.ts", () => { default: "test-model", provider: "custom", base_url: "https://inference.local", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, api_mode: "anthropic_messages", }); }); @@ -694,10 +700,11 @@ describe("agents/hermes/generate-config.ts", () => { default: "nvidia/nvidia/nemotron-3-super-v3", provider: "custom", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, }); expect(config._nemoclaw_upstream).toEqual({ provider: "compatible-anthropic-endpoint", + provider_key: "compatible-anthropic-endpoint", model: "nvidia/nvidia/nemotron-3-super-v3", }); expect(config.custom_providers[0].api_mode).toBeUndefined(); @@ -726,13 +733,13 @@ describe("agents/hermes/generate-config.ts", () => { expect(typeof config.model.api_key).toBe("string"); expect(config.model.api_key.startsWith("sk-")).toBe(true); expect(config.model.api_key).not.toBe("no-key-required"); - expect(config.model.api_key).toBe(HERMES_PROXY_API_KEY_PLACEHOLDER); + expect(config.model.api_key).toBe(HERMES_PROXY_REWRITE_SENTINEL); }); - it("keeps generated and inference-switch Hermes proxy placeholders in sync", () => { + it("keeps generated and inference-switch Hermes proxy rewrite sentinels in sync", () => { const { config } = runConfigScript(); - expect(config.model.api_key).toBe(HERMES_PROXY_API_KEY_PLACEHOLDER); + expect(config.model.api_key).toBe(HERMES_PROXY_REWRITE_SENTINEL); }); it("preserves Hermes remote platform toolsets while keeping CLI defaults unpinned", async () => { @@ -775,6 +782,7 @@ describe("agents/hermes/generate-config.ts", () => { expect(config.tts).toEqual({ provider: "openai", use_gateway: true }); expect(config.stt).toEqual({ provider: "openai", use_gateway: true }); expect(config.browser).toEqual({ + allow_unsafe_evaluate: false, restrict_evaluate: true, cloud_provider: "browser-use", use_gateway: true, @@ -1139,7 +1147,7 @@ describe("agents/hermes/generate-config.ts", () => { default: "moonshotai/kimi-k2.6", provider: "custom", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, }); expect(config.kimi).toBeUndefined(); expect(config.openclawPlugins).toBeUndefined(); diff --git a/test/hermes-dashboard-credential-launch.test.ts b/test/hermes-dashboard-credential-launch.test.ts new file mode 100644 index 00000000000..e808ed3973a --- /dev/null +++ b/test/hermes-dashboard-credential-launch.test.ts @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const WRAPPER = path.join(import.meta.dirname, "..", "agents", "hermes", "hermes-wrapper.py"); +const ADAPTER = path.join( + import.meta.dirname, + "..", + "agents", + "hermes", + "hermes-cli-adapter-v1.json", +); +const PYTHON_AVAILABLE = spawnSync("python3", ["--version"], { timeout: 5_000 }).status === 0; +const GENERATED_KEY = "a".repeat(64); + +let tmpDir: string; + +function runDashboard(sourcePath: string) { + const wrapperPath = path.join(tmpDir, "hermes"); + const capturePath = path.join(tmpDir, "captured-key"); + const argvPath = path.join(tmpDir, "captured-argv"); + const markerPath = path.join(tmpDir, "real-invoked"); + fs.copyFileSync(WRAPPER, wrapperPath); + fs.copyFileSync(ADAPTER, path.join(tmpDir, "hermes-cli-adapter-v1.json")); + fs.writeFileSync( + path.join(tmpDir, "hermes.real"), + [ + "#!/usr/bin/env bash", + 'test -z "${NEMOCLAW_HERMES_DASHBOARD_API_SERVER_ENV:-}" || exit 9', + 'printf "%s" "${API_SERVER_KEY:-}" > "${CAPTURE_PATH}"', + 'printf "%s\\n" "$@" > "${ARGV_PATH}"', + 'touch "${MARKER_PATH}"', + "", + ].join("\n"), + { mode: 0o755 }, + ); + + const result = spawnSync("python3", ["-I", wrapperPath, "dashboard", "--no-open"], { + encoding: "utf8", + timeout: 10_000, + env: { + PATH: process.env.PATH ?? "", + HOME: tmpDir, + CAPTURE_PATH: capturePath, + ARGV_PATH: argvPath, + MARKER_PATH: markerPath, + NEMOCLAW_HERMES_DASHBOARD_API_SERVER_ENV: sourcePath, + }, + }); + return { result, capturePath, argvPath, markerPath }; +} + +describe.skipIf(!PYTHON_AVAILABLE)("Hermes dashboard credential launch", () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dashboard-credential-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("supplies API_SERVER_KEY through process environment without copying it into argv or output (#8008)", () => { + const sourcePath = path.join(tmpDir, "gateway.env"); + fs.writeFileSync(sourcePath, `export API_SERVER_KEY='${GENERATED_KEY}'\n`); + + const { result, capturePath, argvPath, markerPath } = runDashboard(sourcePath); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(markerPath)).toBe(true); + expect(fs.readFileSync(capturePath, "utf8")).toBe(GENERATED_KEY); + expect(fs.readFileSync(argvPath, "utf8")).toBe("dashboard\n--no-open\n"); + expect(result.stdout).not.toContain(GENERATED_KEY); + expect(result.stderr).not.toContain(GENERATED_KEY); + }); + + it("preserves upstream optional authentication for a direct unmanaged dashboard launch", () => { + const { result, capturePath, markerPath } = runDashboard(""); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(markerPath)).toBe(true); + expect(fs.readFileSync(capturePath, "utf8")).toBe(""); + }); + + it.each([ + ["weak", "API_SERVER_KEY=weak\n"], + ["duplicate", `API_SERVER_KEY=${GENERATED_KEY}\nAPI_SERVER_KEY=${"b".repeat(64)}\n`], + ])("refuses a %s API server credential source without launching Hermes (#8008)", (_label, source) => { + const sourcePath = path.join(tmpDir, "gateway.env"); + fs.writeFileSync(sourcePath, source); + + const { result, markerPath } = runDashboard(sourcePath); + + expect(result.status).toBe(1); + expect(fs.existsSync(markerPath)).toBe(false); + expect(result.stderr).toContain("[SECURITY]"); + expect(result.stderr).not.toContain("weak"); + expect(result.stderr).not.toContain(GENERATED_KEY); + }); + + it("refuses a symlinked API server credential source without launching Hermes (#8008)", () => { + const realPath = path.join(tmpDir, "real-gateway.env"); + const sourcePath = path.join(tmpDir, "gateway.env"); + fs.writeFileSync(realPath, `API_SERVER_KEY=${GENERATED_KEY}\n`); + fs.symlinkSync(realPath, sourcePath); + + const { result, markerPath } = runDashboard(sourcePath); + + expect(result.status).toBe(1); + expect(fs.existsSync(markerPath)).toBe(false); + expect(result.stderr).toContain("[SECURITY]"); + expect(result.stderr).not.toContain(GENERATED_KEY); + }); +}); diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index f559dd74a68..9835200b8e0 100644 --- a/test/hermes-dependency-review.test.ts +++ b/test/hermes-dependency-review.test.ts @@ -13,7 +13,7 @@ const dockerfileBase = fs.readFileSync( "utf8", ); const config = fs.readFileSync( - path.join(root, "agents", "hermes", "config", "hermes-config.ts"), + path.join(root, "agents", "hermes", "config", "managed-policy.ts"), "utf8", ); const manifest = fs.readFileSync(path.join(root, "agents", "hermes", "manifest.yaml"), "utf8"); diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index 53d9e4fc83d..4053d094876 100644 --- a/test/hermes-doctor-config-hash.test.ts +++ b/test/hermes-doctor-config-hash.test.ts @@ -114,6 +114,7 @@ describe("Hermes doctor and config hash boundary", () => { "patch-hermes-discord-recovery-permissions.py", ); const profilePolicyPatcherPath = path.join(libDir, "patch-hermes-profile-policy-defaults.py"); + const managedPolicyReaderPath = path.join(libDir, "managed_policy.py"); const mcpCredentialBoundaryPath = path.join( libDir, "openshell-child-visible-credentials.v0.0.85.json", @@ -142,6 +143,7 @@ describe("Hermes doctor and config hash boundary", () => { path.join(libDir, "patch-hermes-session-list-preview.py"), discordRecoveryPatcherPath, profilePolicyPatcherPath, + managedPolicyReaderPath, langfuseCredentialPatcherPath, path.join(libDir, "seed-hermes-dashboard-config.py"), path.join(libDir, "hermes-runtime-config-guard.py"), @@ -199,6 +201,7 @@ describe("Hermes doctor and config hash boundary", () => { expect(mode(mcpConfigTransactionPath)).toBe("755"); expect(mode(discordRecoveryPatcherPath)).toBe("755"); expect(mode(profilePolicyPatcherPath)).toBe("755"); + expect(mode(managedPolicyReaderPath)).toBe("444"); expect(mode(langfuseCredentialPatcherPath)).toBe("444"); expect(mode(mcpCredentialBoundaryPath)).toBe("444"); expect(mode(buildMcpDigestPath)).toBe("444"); diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index c98cb3b64d8..d9d4d96a36e 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -253,6 +253,7 @@ describe("Hermes final image layout", () => { "COPY agents/hermes/patch-cron-restore-drain.py /opt/nemoclaw-hermes-config/patch-cron-restore-drain.py", "COPY agents/hermes/patch-neutral-platform-env-activation.py /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py", "COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json", + "COPY src/lib/hermes-managed-route.ts /src/lib/hermes-managed-route.ts", "COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts", "COPY src/lib/messaging/ /src/lib/messaging/", "COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts", @@ -278,6 +279,7 @@ describe("Hermes final image layout", () => { "COPY agents/hermes/patch-session-list-preview.py /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py", "COPY agents/hermes/patch-discord-recovery-permissions.py /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py", "COPY agents/hermes/patch-profile-policy-defaults.py /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py", + "COPY agents/hermes/managed_policy.py /usr/local/lib/nemoclaw/managed_policy.py", "COPY agents/hermes/patch-langfuse-credentials.mts /usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts", "COPY agents/hermes/seed-dashboard-config.py /usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py", "COPY agents/hermes/runtime-config-guard.py /usr/local/lib/nemoclaw/hermes-runtime-config-guard.py", @@ -393,6 +395,7 @@ describe("Hermes final image layout", () => { expect(agent).toBeLessThan(agentChmod); expect(cronRestoreDrainPatch).toBeLessThan(profilePolicyPatch); expect(profilePolicyPatch).toBeLessThan(neutralPlatformPatch); + expect(neutralPlatformPatch).toBeLessThan(neutralMessagingConfig); expect(managedMessagingUnionInstall).toBeLessThan(neutralMessagingConfig); expect(runtime).toBeGreaterThan(configFind); expect(runtime).toBeLessThan(managedRuntimeDirectory); @@ -418,6 +421,8 @@ describe("Hermes final image layout", () => { "/usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py 'root:root 755'", "/usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py 'root:root 755'", "/usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py 'root:root 755'", + "/usr/local/lib/nemoclaw/managed_policy.py 'root:root 444'", + "/usr/local/share/nemoclaw/hermes-managed-policy.json 'root:root 444'", "/usr/local/bin/nemoclaw-managed-bootstrap 'root:root 755'", "/usr/local/lib/nemoclaw/managed-bootstrap-trampoline.sh 'root:root 444'", "/usr/local/bin/nemoclaw-gateway-control 'root:root 700'", @@ -441,16 +446,16 @@ describe("Hermes final image layout", () => { expect(doctorLayer).toContain('assert m.version("microsoft-teams-apps") == "2.0.13.4"'); expect(doctorLayer).toContain('assert m.version("aiohttp") == "3.14.3"'); expect(doctorLayer).toContain("assert len(neutral) == 30"); - expect(doctorLayer).toContain("neutral-platform-inertness"); - expect(doctorLayer).toContain("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON"); - expect(doctorLayer).toContain("WHATSAPP_CLOUD_ACCESS_TOKEN"); + expect(finalStage).toContain("neutral-platform-inertness"); + expect(finalStage).toContain("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON"); + expect(finalStage).toContain("WHATSAPP_CLOUD_ACCESS_TOKEN"); expect(finalStage).toContain( "ARG NEMOCLAW_HERMES_POST_PROFILE_GATEWAY_CONFIG_SHA256=" + - "2084c652a07614761d85703787f8697fc29560fe447f23362aa0bda5179dffa7", + "b50a8390311c828fa9e13084e9af0caadafe2380ae161ef36dd4bdf792b22ee6", ); expect(finalStage).toContain( "ARG NEMOCLAW_HERMES_NEUTRAL_PLATFORM_OUTPUT_SHA256=" + - "5a1375664d1451b2fe9c3f2325f673149a90b9035588dfd4eb6618f785ecd6a2", + "77ad342af30d59a5b863d9f5f817247d816fd582fb12d38e074243f88d85b9f4", ); expect(doctorLayer).toMatch(/generate-config[.]ts\s+&& if /u); expect(doctorLayer).toMatch(/fi\s+&& rm -rf \/sandbox\/[.]cache$/u); @@ -462,6 +467,7 @@ describe("Hermes final image layout", () => { "&& check_absent /sandbox/.nemoclaw/hermes-cron-restore-drain.json \\", ); expect(finalStage).toContain("&& check_absent /sandbox/.cache \\"); + expect(finalStage).toContain("&& check_absent /sandbox/.hermes/managed-policy.json \\"); expect(finalStage).toContain("RUN chown root:root /sandbox/.nemoclaw \\"); expect(finalStage).toContain("&& chmod 1755 /sandbox/.nemoclaw \\"); expect(finalStage).toContain("&& chown sandbox:sandbox /sandbox/.nemoclaw/config.json"); @@ -523,7 +529,7 @@ describe("Hermes final image layout", () => { it("migrates legacy data into the current state directory", () => { const run = runFinalLayout({ legacyData: "content" }); try { - expect(run.result.status).toBe(0); + expect(run.result.status, run.result.stderr).toBe(0); expect( fs.lstatSync(path.join(run.sandboxRoot, ".hermes-data"), { throwIfNoEntry: false, diff --git a/test/hermes-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index 1f6a738e77f..d5d61aa6ff4 100644 --- a/test/hermes-gateway-wrapper.test.ts +++ b/test/hermes-gateway-wrapper.test.ts @@ -20,7 +20,7 @@ import path from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; -import { buildHermesConfig } from "../agents/hermes/config/hermes-config.ts"; +import { buildHermesManagedPolicy } from "../agents/hermes/config/managed-policy.ts"; import { buildOpenshellExecArgs } from "../src/lib/actions/sandbox/exec.ts"; import { canRun, runWrapper, VALIDATOR, WRAPPER } from "./helpers/hermes-wrapper-harness.ts"; @@ -1046,7 +1046,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { } }); - it("masks every api_key emitted by buildHermesConfig so the generated config cannot leak through `config show`", () => { + it("masks every api_key emitted by the managed policy so generated config cannot leak through `config show`", () => { const settings = { model: "meta/llama-3.1-8b-instruct", baseUrl: "https://inference.local/v1", @@ -1060,7 +1060,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { messagingCredentialPlaceholders: [], managedToolGateways: { brokerEnabled: false, presets: [] }, }; - const generated = buildHermesConfig(settings); + const generated = buildHermesManagedPolicy(settings).config; const fixture = JSON.stringify(generated, null, 2); expect(fixture).toContain("sk-OPENSHELL-PROXY-REWRITE"); const run = runWrapper(["config", "show"], {}, { stub: { stdout: fixture, exitCode: 0 } }); @@ -1109,7 +1109,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { messagingCredentialPlaceholders: [], managedToolGateways: { brokerEnabled: false, presets: [] }, }; - const generated = buildHermesConfig(settings); + const generated = buildHermesManagedPolicy(settings).config; const fixture = JSON.stringify(generated, null, 2); const stubScript = [ "#!/usr/bin/env bash", diff --git a/test/hermes-managed-policy.test.ts b/test/hermes-managed-policy.test.ts new file mode 100644 index 00000000000..4acade7fdd9 --- /dev/null +++ b/test/hermes-managed-policy.test.ts @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { HermesBuildSettings } from "../agents/hermes/config/build-env.ts"; +import { + buildHermesManagedPolicy, + HERMES_MANAGED_POLICY_SCHEMA_VERSION, +} from "../agents/hermes/config/managed-policy.ts"; + +const READER_PATH = path.join(import.meta.dirname, "..", "agents", "hermes", "managed_policy.py"); +const PROFILE_PATCHER_PATH = path.join( + import.meta.dirname, + "..", + "agents", + "hermes", + "patch-profile-policy-defaults.py", +); +const DASHBOARD_SEEDER_PATH = path.join( + import.meta.dirname, + "..", + "agents", + "hermes", + "seed-dashboard-config.py", +); +const SETTINGS: HermesBuildSettings = { + model: "test-model", + baseUrl: "https://inference.local/v1", + providerKey: "nvidia-router", + upstreamProvider: "NVIDIA Router", + inferenceApi: "openai-completions", + contextWindow: 128_000, + toolDisclosure: "progressive", + webSearchProvider: "tavily", + messagingCredentialPlaceholders: [ + { + envKey: "DISCORD_BOT_TOKEN", + placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", + }, + ], + managedToolGateways: { brokerEnabled: false, presets: [] }, + managedImageCapabilityUnion: false, +}; + +const PYTHON_LOAD = ` +import importlib.util +import pathlib +import sys + +reader_path = pathlib.Path(sys.argv[1]) +spec = importlib.util.spec_from_file_location("managed_policy", reader_path) +assert spec and spec.loader +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +try: + module.load_managed_policy(pathlib.Path(sys.argv[2])) +except module.ManagedPolicyError as exc: + print(exc, file=sys.stderr) + raise SystemExit(1) +`; + +function loadWithPython(document: unknown) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-managed-policy-")); + const policyPath = path.join(tmp, "managed-policy.json"); + fs.writeFileSync(policyPath, `${JSON.stringify(document)}\n`); + try { + return spawnSync("python3", ["-I", "-c", PYTHON_LOAD, READER_PATH, policyPath], { + encoding: "utf8", + timeout: 5000, + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +describe("Hermes managed policy", () => { + it("serializes one versioned policy with resolver-only credentials (#8008)", () => { + const rawSecret = "raw-secret-must-not-appear"; + const policy = buildHermesManagedPolicy(SETTINGS, { DISCORD_BOT_TOKEN: rawSecret }); + const serialized = JSON.stringify(policy); + + expect(policy.schema_version).toBe(HERMES_MANAGED_POLICY_SCHEMA_VERSION); + expect(Object.keys(policy).sort()).toEqual([ + "config", + "dashboard", + "env_lines", + "managed_paths", + "schema_version", + ]); + expect(policy.config._nemoclaw_upstream).toEqual({ + provider: "NVIDIA Router", + provider_key: "nvidia-router", + model: "test-model", + }); + expect(policy.env_lines).toContain("DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN"); + expect(policy.dashboard.env_keys).not.toContain("API_SERVER_KEY"); + expect(serialized).not.toContain(rawSecret); + expect(loadWithPython(policy).status).toBe(0); + }); + + it("rejects a schema change without an explicit migration (#8008)", () => { + const policy = { + ...buildHermesManagedPolicy(SETTINGS, {}), + schema_version: HERMES_MANAGED_POLICY_SCHEMA_VERSION + 1, + }; + + const result = loadWithPython(policy); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("has no migration to 1"); + }); + + it("rejects a raw model credential without echoing it (#8008)", () => { + const rawCredential = "sk-raw-policy-credential"; + const policy = structuredClone(buildHermesManagedPolicy(SETTINGS, {})); + const malformedPolicy = { + ...policy, + config: { + ...policy.config, + model: { ...policy.config.model, api_key: rawCredential }, + }, + }; + + const result = loadWithPython(malformedPolicy); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must use the OpenShell proxy rewrite sentinel"); + expect(result.stderr).not.toContain(rawCredential); + }); + + it("loads the shared reader under the image's isolated Python mode (#8008)", () => { + const patcher = spawnSync("python3", ["-I", PROFILE_PATCHER_PATH, "--help"], { + encoding: "utf8", + timeout: 5000, + }); + const seeder = spawnSync("python3", ["-I", DASHBOARD_SEEDER_PATH], { + encoding: "utf8", + timeout: 5000, + }); + + expect(patcher.status, patcher.stderr).toBe(0); + expect(seeder.status).toBe(1); + expect(seeder.stderr).toContain("usage: seed-dashboard-config.py"); + expect(seeder.stderr).not.toContain("ModuleNotFoundError"); + }); +}); diff --git a/test/hermes-profile-policy-defaults.test.ts b/test/hermes-profile-policy-defaults.test.ts index 6455ea68137..ed9ab41078b 100644 --- a/test/hermes-profile-policy-defaults.test.ts +++ b/test/hermes-profile-policy-defaults.test.ts @@ -4,17 +4,31 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import type { HermesBuildSettings } from "../agents/hermes/config/build-env.ts"; +import { buildHermesManagedPolicy } from "../agents/hermes/config/managed-policy.ts"; const root = path.join(import.meta.dirname, ".."); const patcher = path.join(root, "agents", "hermes", "patch-profile-policy-defaults.py"); +const imageBuildProbes = path.join(root, "agents", "hermes", "image-build-probes.py"); const dockerfile = fs.readFileSync(path.join(root, "agents", "hermes", "Dockerfile"), "utf8"); -const imageBuildProbes = fs.readFileSync( - path.join(root, "agents", "hermes", "image-build-probes.py"), - "utf8", -); +const POLICY_SETTINGS: HermesBuildSettings = { + model: "test-model", + baseUrl: "https://inference.local/v1", + providerKey: "custom", + upstreamProvider: "custom", + inferenceApi: "openai-completions", + contextWindow: null, + toolDisclosure: "progressive", + webSearchProvider: null, + messagingCredentialPlaceholders: [], + managedToolGateways: { brokerEnabled: false, presets: [] }, + managedImageCapabilityUnion: false, +}; +const MANAGED_POLICY = buildHermesManagedPolicy(POLICY_SETTINGS, {}); const configFixture = `\ DEFAULT_CONFIG = { @@ -117,21 +131,30 @@ import sys spec = importlib.util.spec_from_file_location("profile_policy_patcher", pathlib.Path(sys.argv[1])) assert spec and spec.loader +sys.path.insert(0, str(pathlib.Path(sys.argv[1]).parent)) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) source = sys.stdin.read() +values = module.profile_default_values(module.load_managed_policy(pathlib.Path(sys.argv[3]))) try: - patched = getattr(module, "patch_" + sys.argv[2] + "_source")(source) + patched = getattr(module, "patch_" + sys.argv[2] + "_source")(source, values) except ValueError as exc: print(exc, file=sys.stderr) raise SystemExit(1) sys.stdout.write(patched) `; - return spawnSync("python3", ["-I", "-c", harness, patcher, kind], { - encoding: "utf8", - input: source, - timeout: 5000, - }); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-profile-policy-")); + const policyPath = path.join(tmp, "managed-policy.json"); + fs.writeFileSync(policyPath, `${JSON.stringify(MANAGED_POLICY)}\n`); + try { + return spawnSync("python3", ["-I", "-c", harness, patcher, kind, policyPath], { + encoding: "utf8", + input: source, + timeout: 5000, + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } } describe("Hermes profile policy defaults", () => { @@ -193,7 +216,7 @@ describe("Hermes profile policy defaults", () => { expect(result.stdout).not.toContain('.get("show_commentary", True)'); expect(result.stdout.match(/agent[.]show_commentary = False/gu)).toHaveLength(2); expect(result.stdout).toContain('.get("show_commentary", False)'); - expect(result.stdout.match(/NemoClaw compatibility override/gu)).toHaveLength(3); + expect(result.stdout.match(/NemoClaw compatibility override/gu)).toHaveLength(1); }); it("keeps update backup and CUA refresh fallbacks off", () => { @@ -204,7 +227,7 @@ describe("Hermes profile policy defaults", () => { expect(result.stdout).toContain("refresh_cua_driver = False"); expect(result.stdout).toContain('_update_cfg.get("refresh_cua_driver", False)'); expect(result.stdout).not.toContain('updates_cfg.get("pre_update_backup", "quick")'); - expect(result.stdout.match(/NemoClaw compatibility override/gu)).toHaveLength(3); + expect(result.stdout.match(/NemoClaw compatibility override/gu)).toHaveLength(2); }); it.each([ @@ -233,6 +256,57 @@ describe("Hermes profile policy defaults", () => { expect(result.stderr).toContain(error); }); + it("reports an invalid managed policy as a bounded build error", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-profile-policy-error-")); + const policyPath = path.join(tmp, "managed-policy.json"); + fs.writeFileSync(policyPath, "not-json\n"); + const result = spawnSync("python3", [patcher, "--policy", policyPath], { + encoding: "utf8", + timeout: 5000, + }); + fs.rmSync(tmp, { recursive: true, force: true }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain(`ERROR: ${policyPath}: managed policy is malformed`); + expect(result.stderr).not.toContain("Traceback"); + }); + + it("checks session reset defaults at their gateway boundary for a config-less profile", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-profile-probe-")); + const policyPath = path.join(tmp, "managed-policy.json"); + fs.writeFileSync(policyPath, `${JSON.stringify(MANAGED_POLICY)}\n`); + const harness = `\ +import copy +import importlib.util +import json +import pathlib +import sys +from types import SimpleNamespace + +probe_path = pathlib.Path(sys.argv[1]) +policy_path = pathlib.Path(sys.argv[2]) +sys.path.insert(0, str(probe_path.parent)) +from managed_policy import profile_default_values +spec = importlib.util.spec_from_file_location("image_build_probes", probe_path) +assert spec and spec.loader +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +policy = json.loads(policy_path.read_text(encoding="utf-8")) +expected = profile_default_values(policy) +config = copy.deepcopy(policy["config"]) +reset_policy = SimpleNamespace(**config.pop("session_reset")) +module._verify_profile_config_policy(config, expected) +module._verify_session_reset_policy(reset_policy, expected) +`; + const result = spawnSync("python3", ["-I", "-c", harness, imageBuildProbes, policyPath], { + encoding: "utf8", + timeout: 5000, + }); + fs.rmSync(tmp, { recursive: true, force: true }); + + expect(result.status, result.stderr).toBe(0); + }); + it("hash-binds the reviewed source patch and probes a real config-less profile", () => { const digest = createHash("sha256").update(fs.readFileSync(patcher)).digest("hex"); @@ -254,14 +328,7 @@ describe("Hermes profile policy defaults", () => { } expect(dockerfile).toContain("hermes profile create nemoclaw-policy-probe"); expect(dockerfile).toContain('test ! -e "$profile_probe_home/config.yaml"'); - expect(imageBuildProbes).toContain('assert config["approvals"]["mode"] == "manual"'); - expect(imageBuildProbes).toContain("assert _restrict_browser_evaluate() is True"); - expect(imageBuildProbes).toContain('assert SessionResetPolicy.from_dict({}).mode == "both"'); - expect(imageBuildProbes).toContain('assert CLI_CONFIG["display"]["show_reasoning"] is False'); - expect(imageBuildProbes).toContain("assert _load_show_reasoning() is False"); - expect(imageBuildProbes).toContain( - 'assert agent_source.count("agent.show_commentary = True") == 0', - ); - expect(imageBuildProbes).toContain('assert _resolve_pre_update_backup_mode(args) == "off"'); + expect(dockerfile).toContain("/usr/local/share/nemoclaw/hermes-managed-policy.json"); + expect(dockerfile).toMatch(/image-build-probes[.]py\s+profile-policy/u); }); }); diff --git a/test/hermes-start-config-integrity.test.ts b/test/hermes-start-config-integrity.test.ts index aed46aa498a..b9c4ca8dcad 100644 --- a/test/hermes-start-config-integrity.test.ts +++ b/test/hermes-start-config-integrity.test.ts @@ -115,6 +115,7 @@ function runHermesDashboardHomePrepAsRoot() { `HERMES_DASHBOARD_HOME=${shellQuote(dashboardHome)}`, `_HERMES_PYTHON=${shellQuote(fakePython)}`, `_HERMES_DASHBOARD_CONFIG_SEEDER=${shellQuote(path.join(tmpDir, "seed-dashboard-config.py"))}`, + `_HERMES_MANAGED_POLICY=${shellQuote(path.join(tmpDir, "managed-policy.json"))}`, "STEP_DOWN_PREFIX_SANDBOX=(env NEMOCLAW_TEST_STEPPED_DOWN=1)", "prepare_hermes_dashboard_home sandbox:sandbox", `if [ -e ${shellQuote(path.join(dashboardHome, "gateway_state.json"))} ]; then echo gateway_state_exists=1; else echo gateway_state_exists=0; fi`, @@ -203,7 +204,9 @@ describe("agents/hermes/start.sh config integrity", () => { expect(result.stdout).toContain("cmd=rm stepped=1"); expect(result.stdout).toContain("cmd=python stepped=1"); expect(result.stdout).not.toContain("cmd=chown"); - expect(result.stdout).toContain("/config.yaml"); + expect(result.stdout).toMatch( + /seed-dashboard-config[.]py\s+[^\s]*managed-policy[.]json\s+[^\s]*config[.]yaml/u, + ); expect(result.stdout).toContain("/.env"); }); diff --git a/test/managed-image-capability-union.test.ts b/test/managed-image-capability-union.test.ts index 65df8376657..583262c7d05 100644 --- a/test/managed-image-capability-union.test.ts +++ b/test/managed-image-capability-union.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest"; import { MANAGED_IMAGE_HERMES_NEUTRAL_PLATFORMS, MANAGED_IMAGE_HERMES_SUPPORTED_PLATFORMS, -} from "../agents/hermes/config/hermes-config.ts"; +} from "../agents/hermes/config/managed-policy.ts"; import { MANAGED_IMAGE_OPENCLAW_MESSAGING_CAPABILITIES } from "../scripts/generate-openclaw-config.mts"; import { applyMessagingBuildPhase, diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 77e66a207e2..808d2acf84f 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -1112,6 +1112,7 @@ describe("Hermes sandbox provisioning", () => { localLib, "patch-hermes-langfuse-credentials.mts", ); + const managedPolicyReaderPath = path.join(localLib, "managed_policy.py"); const mcpManifest = path.join(localLib, "openshell-child-visible-credentials.v0.0.85.json"); const stateDirGuardPath = path.join(localLib, "state-dir-guard.py"); const stateLockPlanPath = path.join( @@ -1135,6 +1136,7 @@ describe("Hermes sandbox provisioning", () => { path.join(localLib, "patch-hermes-session-list-preview.py"), path.join(localLib, "patch-hermes-discord-recovery-permissions.py"), path.join(localLib, "patch-hermes-profile-policy-defaults.py"), + managedPolicyReaderPath, langfuseCredentialPatcherPath, path.join(localLib, "seed-hermes-dashboard-config.py"), path.join(localLib, "hermes-runtime-config-guard.py"), @@ -1180,6 +1182,7 @@ describe("Hermes sandbox provisioning", () => { expect((fs.statSync(langfuseCredentialPatcherPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(mcpManifest).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(buildMcpDigestPath).mode & 0o777).toString(8)).toBe("444"); + expect((fs.statSync(managedPolicyReaderPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(gatewaySupervisorPath).mode & 0o777).toString(8)).toBe("444"); expect((fs.statSync(stateDirGuardPath).mode & 0o777).toString(8)).toBe("500"); expect((fs.statSync(stateLockPlanPath).mode & 0o777).toString(8)).toBe("444"); diff --git a/test/sandbox-rlimit-hooks.test.ts b/test/sandbox-rlimit-hooks.test.ts index 83e5683e2cf..67e3a1352af 100644 --- a/test/sandbox-rlimit-hooks.test.ts +++ b/test/sandbox-rlimit-hooks.test.ts @@ -559,6 +559,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { "patch-hermes-discord-recovery-permissions.py", ); const profilePolicyPatcher = path.join(localLib, "patch-hermes-profile-policy-defaults.py"); + const managedPolicyReader = path.join(localLib, "managed_policy.py"); const langfuseCredentialPatcher = path.join(localLib, "patch-hermes-langfuse-credentials.mts"); const dashboardSeeder = path.join(localLib, "seed-hermes-dashboard-config.py"); const runtimeGuard = path.join(localLib, "hermes-runtime-config-guard.py"); @@ -594,6 +595,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { fs.writeFileSync(sessionListPreviewPatcher, "# session list preview patcher fixture\n"); fs.writeFileSync(discordRecoveryPatcher, "# Discord recovery patcher fixture\n"); fs.writeFileSync(profilePolicyPatcher, "# profile policy patcher fixture\n"); + fs.writeFileSync(managedPolicyReader, "# managed policy reader fixture\n"); fs.writeFileSync(langfuseCredentialPatcher, "# Langfuse credential patcher fixture\n"); fs.writeFileSync(dashboardSeeder, "# dashboard seeder fixture\n"); fs.writeFileSync(runtimeGuard, "# runtime guard fixture\n"); @@ -644,6 +646,7 @@ describe("sandbox rlimit system hooks (#2173)", () => { "/usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py", profilePolicyPatcher, ) + .replaceAll("/usr/local/lib/nemoclaw/managed_policy.py", managedPolicyReader) .replaceAll( "/usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts", langfuseCredentialPatcher, diff --git a/test/seed-hermes-dashboard-config.test.ts b/test/seed-hermes-dashboard-config.test.ts index 08b9c90028c..0b279220cf6 100644 --- a/test/seed-hermes-dashboard-config.test.ts +++ b/test/seed-hermes-dashboard-config.test.ts @@ -18,6 +18,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import YAML from "yaml"; +import type { HermesBuildSettings } from "../agents/hermes/config/build-env.ts"; +import { buildHermesManagedPolicy } from "../agents/hermes/config/managed-policy.ts"; const SCRIPT_PATH = path.join( import.meta.dirname, @@ -36,9 +38,23 @@ const GENERATED_HEX_TOKEN = Array.from({ length: 64 }, (_value, index) => ).join(""); const TAVILY_API_KEY_PLACEHOLDER = "openshell:resolve:env:TAVILY_API_KEY"; -const REVIEWED_POLICY = { +const POLICY_SETTINGS: HermesBuildSettings = { + model: "nvidia-routed", + baseUrl: "https://inference.local/v1", + providerKey: "nvidia-router", + upstreamProvider: "nvidia-router", + inferenceApi: "anthropic-messages", + contextWindow: null, + toolDisclosure: "progressive", + webSearchProvider: "tavily", + messagingCredentialPlaceholders: [], + managedToolGateways: { brokerEnabled: false, presets: [] }, + managedImageCapabilityUnion: false, +}; +const MANAGED_POLICY = buildHermesManagedPolicy(POLICY_SETTINGS, {}); +const EXPECTED_DASHBOARD_POLICY = { approvals: { mode: "manual" }, - browser: { restrict_evaluate: true }, + browser: { allow_unsafe_evaluate: false, restrict_evaluate: true }, session_reset: { mode: "both", at_hour: 4, @@ -47,24 +63,29 @@ const REVIEWED_POLICY = { notify_exclude_platforms: ["api_server", "webhook"], bg_process_max_age_hours: 24, }, - display: { - show_reasoning: false, - show_commentary: false, - }, - updates: { - pre_update_backup: false, - refresh_cua_driver: false, - }, + display: { show_reasoning: false, show_commentary: false }, + updates: { pre_update_backup: false, refresh_cua_driver: false }, }; +const GATEWAY_POLICY = Object.fromEntries( + Array.from( + new Set(MANAGED_POLICY.managed_paths.map((path) => path.split(".", 1)[0])), + (section) => [section, MANAGED_POLICY.config[section]], + ), +); const GATEWAY_CONFIG = { _config_version: 12, - _nemoclaw_upstream: { provider: "nvidia-router", model: "nvidia-routed" }, + _nemoclaw_upstream: { + provider: "nvidia-router", + provider_key: "nvidia-router", + model: "nvidia-routed", + }, model: { default: "nvidia-routed", - provider: "nvidia-router", + provider: "custom", base_url: "https://inference.local/v1", api_key: "sk-OPENSHELL-PROXY-REWRITE", + api_mode: "anthropic_messages", }, providers: { "nvidia-router": { @@ -73,6 +94,7 @@ const GATEWAY_CONFIG = { api_key: "sk-OPENSHELL-PROXY-REWRITE", default_model: "nvidia-routed", discover_models: true, + transport: "anthropic_messages", }, }, custom_providers: [ @@ -81,15 +103,18 @@ const GATEWAY_CONFIG = { base_url: "https://inference.local/v1", api_key: "sk-OPENSHELL-PROXY-REWRITE", discover_models: true, + api_mode: "anthropic_messages", }, ], // Intentionally present to assert it is NOT mirrored (would collide with the // gateway's api_server bind). platforms: { api_server: { enabled: true, extra: { port: 18642 } } }, - ...REVIEWED_POLICY, + web: { backend: "tavily" }, + ...GATEWAY_POLICY, }; let tmpDir: string; +let policyPath: string; function runSeed( srcPath: string, @@ -103,6 +128,7 @@ function runSeed( const args = [ SCRIPT_PATH, ...(mergeLegacy ? ["--merge-legacy"] : []), + policyPath, srcPath, dstPath, ...envArgs, @@ -132,6 +158,8 @@ function readYaml(p: string): Record { describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "seed-dash-")); + policyPath = path.join(tmpDir, "managed-policy.json"); + fs.writeFileSync(policyPath, `${JSON.stringify(MANAGED_POLICY)}\n`); }); afterEach(() => { @@ -146,15 +174,15 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { expect(res.status).toBe(0); const dash = readYaml(dst); - expect(dash.model).toEqual(GATEWAY_CONFIG.model); + expect(dash.model).toEqual({ ...GATEWAY_CONFIG.model, provider: "nvidia-router" }); expect(dash.providers).toEqual(GATEWAY_CONFIG.providers); expect(dash.custom_providers).toEqual(GATEWAY_CONFIG.custom_providers); expect(dash._nemoclaw_upstream).toEqual(GATEWAY_CONFIG._nemoclaw_upstream); - expect(dash.approvals).toEqual(REVIEWED_POLICY.approvals); - expect(dash.browser).toEqual(REVIEWED_POLICY.browser); - expect(dash.session_reset).toEqual(REVIEWED_POLICY.session_reset); - expect(dash.display).toEqual(REVIEWED_POLICY.display); - expect(dash.updates).toEqual(REVIEWED_POLICY.updates); + expect(dash.approvals).toEqual(EXPECTED_DASHBOARD_POLICY.approvals); + expect(dash.browser).toEqual(EXPECTED_DASHBOARD_POLICY.browser); + expect(dash.session_reset).toEqual(EXPECTED_DASHBOARD_POLICY.session_reset); + expect(dash.display).toEqual(EXPECTED_DASHBOARD_POLICY.display); + expect(dash.updates).toEqual(EXPECTED_DASHBOARD_POLICY.updates); }); it("moves the legacy dashboard home into the Hermes profiles directory (#7200)", () => { @@ -174,7 +202,10 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { expect(fs.statSync(path.dirname(dashboardHome)).isDirectory()).toBe(true); expect(fs.statSync(dashboardHome).mode & 0o777).toBe(0o700); expect(fs.readFileSync(path.join(dashboardHome, "MEMORY.md"), "utf-8")).toBe("keep me\n"); - expect(readYaml(path.join(dashboardHome, "config.yaml")).model).toEqual(GATEWAY_CONFIG.model); + expect(readYaml(path.join(dashboardHome, "config.yaml")).model).toEqual({ + ...GATEWAY_CONFIG.model, + provider: "nvidia-router", + }); }); it("keeps seeding anchored when profiles is swapped after migration (#7200)", () => { @@ -204,7 +235,11 @@ if not ok or dashboard_fd is None: try: os.rename(sys.argv[4], sys.argv[5]) os.symlink(sys.argv[6], sys.argv[4]) - raise SystemExit(module._seed_dashboard([sys.argv[1], sys.argv[2], sys.argv[3]], dashboard_fd)) + raise SystemExit( + module._seed_dashboard( + [sys.argv[1], sys.argv[7], sys.argv[2], sys.argv[3]], dashboard_fd + ) + ) finally: os.close(dashboard_fd) `; @@ -219,6 +254,7 @@ finally: profilesDir, heldProfiles, outsideProfiles, + policyPath, ], { encoding: "utf-8", @@ -230,9 +266,10 @@ finally: expect(res.status).toBe(0); expect(fs.lstatSync(profilesDir).isSymbolicLink()).toBe(true); expect(fs.existsSync(path.join(outsideProfiles, "dashboard-home", "config.yaml"))).toBe(false); - expect(readYaml(path.join(heldProfiles, "dashboard-home", "config.yaml")).model).toEqual( - GATEWAY_CONFIG.model, - ); + expect(readYaml(path.join(heldProfiles, "dashboard-home", "config.yaml")).model).toEqual({ + ...GATEWAY_CONFIG.model, + provider: "nvidia-router", + }); expect(fs.readFileSync(path.join(heldProfiles, "dashboard-home", "MEMORY.md"), "utf-8")).toBe( "keep me\n", ); @@ -406,21 +443,19 @@ raise SystemExit(0 if not ok and dashboard_fd is None else 2) expect(readYaml(dst).web).toEqual({ max_results: 3, backend: "tavily" }); }); - it("removes the managed Tavily backend after the gateway disables it", () => { - const enabledSrc = writeYaml("gw-enabled.yaml", { - ...GATEWAY_CONFIG, - web: { backend: "tavily" }, - }); - const disabledSrc = writeYaml("gw-disabled.yaml", GATEWAY_CONFIG); - const dst = writeYaml("dash.yaml", { web: { max_results: 3 } }); + it("rejects Tavily policy drift without changing the dashboard config", () => { + const src = writeYaml("gw.yaml", { ...GATEWAY_CONFIG, web: undefined }); + const dst = writeYaml("dash.yaml", { web: { max_results: 3, backend: "tavily" } }); + const before = fs.readFileSync(dst, "utf8"); - expect(runSeed(enabledSrc, dst).status).toBe(0); - expect(readYaml(dst).web).toEqual({ max_results: 3, backend: "tavily" }); - expect(runSeed(disabledSrc, dst).status).toBe(0); - expect(readYaml(dst).web).toEqual({ max_results: 3 }); + const result = runSeed(src, dst); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("gateway policy is invalid"); + expect(fs.readFileSync(dst, "utf8")).toBe(before); }); - it("synthesizes Hermes v16 providers from legacy gateway routing", () => { + it("rejects legacy routing that has no canonical provider key", () => { const legacy = { _config_version: 12, _nemoclaw_upstream: { provider: "NVIDIA Router", model: "nvidia-routed" }, @@ -438,30 +473,53 @@ raise SystemExit(0 if not ok and dashboard_fd is None else 2) discover_models: true, }, ], - ...REVIEWED_POLICY, + web: { backend: "tavily" }, + ...GATEWAY_POLICY, }; const src = writeYaml("gw.yaml", legacy); const dst = path.join(tmpDir, "dash.yaml"); const res = runSeed(src, dst); - expect(res.status).toBe(0); + expect(res.status).toBe(1); + expect(res.stderr).toContain("invalid model routing"); + expect(fs.existsSync(dst)).toBe(false); + }); - const dash = readYaml(dst); - expect(dash.model).toEqual({ - default: "nvidia-routed", - provider: "nvidia-router", - base_url: "https://inference.local/v1", - api_key: "sk-OPENSHELL-PROXY-REWRITE", - }); - expect(dash.providers).toEqual({ - "nvidia-router": { - name: "NVIDIA Router", - api: "https://inference.local/v1", - api_key: "sk-OPENSHELL-PROXY-REWRITE", - default_model: "nvidia-routed", - discover_models: true, - }, - }); + it("rejects raw credentials in every mirrored routing shape (#8008)", () => { + const cases: Array<[string, (gateway: typeof GATEWAY_CONFIG) => void]> = [ + [ + "model", + (gateway) => { + gateway.model.api_key = "sk-raw-model-credential"; + }, + ], + [ + "provider", + (gateway) => { + gateway.providers["nvidia-router"].api_key = "sk-raw-provider-credential"; + }, + ], + [ + "custom provider", + (gateway) => { + gateway.custom_providers[0].api_key = "sk-raw-custom-provider-credential"; + }, + ], + ]; + for (const [location, injectRawCredential] of cases) { + const gateway = structuredClone(GATEWAY_CONFIG); + injectRawCredential(gateway); + const src = writeYaml(`gw-${location}.yaml`, gateway); + const dst = writeYaml(`dash-${location}.yaml`, { dashboard_local: true }); + const before = fs.readFileSync(dst, "utf8"); + + const result = runSeed(src, dst); + + expect(result.status, location).toBe(1); + expect(result.stderr, location).toContain("[SECURITY]"); + expect(result.stderr, location).not.toContain("sk-raw-"); + expect(fs.readFileSync(dst, "utf8"), location).toBe(before); + } }); it("mirrors only dashboard-needed gateway .env keys for Hermes 0.16 chat setup", () => { @@ -493,7 +551,6 @@ raise SystemExit(0 if not ok and dashboard_fd is None else 2) [ "API_SERVER_HOST=127.0.0.1", "API_SERVER_PORT=18642", - `API_SERVER_KEY=${GENERATED_HEX_TOKEN}`, `TAVILY_API_KEY=${TAVILY_API_KEY_PLACEHOLDER}`, "FIRECRAWL_GATEWAY_URL=http://host.openshell.internal:11436/firecrawl", "NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=1", @@ -504,7 +561,7 @@ raise SystemExit(0 if not ok and dashboard_fd is None else 2) expect(fs.statSync(envDst).mode & 0o777).toBe(0o600); }); - it("mirrors export-prefixed API_SERVER_KEY into the dashboard .env", () => { + it("keeps API_SERVER_KEY out of the dashboard .env mirror", () => { const src = writeYaml("gw.yaml", GATEWAY_CONFIG); const dst = path.join(tmpDir, "dash.yaml"); const envSrc = path.join(tmpDir, "gw.env"); @@ -522,12 +579,10 @@ raise SystemExit(0 if not ok and dashboard_fd is None else 2) const res = runSeed(src, dst, envSrc, envDst); expect(res.status).toBe(0); - expect(fs.readFileSync(envDst, "utf-8")).toBe( - [`export API_SERVER_KEY=${GENERATED_HEX_TOKEN}`, "API_SERVER_HOST=127.0.0.1", ""].join("\n"), - ); + expect(fs.readFileSync(envDst, "utf-8")).toBe("API_SERVER_HOST=127.0.0.1\n"); }); - it("rejects weak API_SERVER_KEY values instead of mirroring them into the dashboard .env", () => { + it("ignores API_SERVER_KEY values instead of parsing or mirroring them", () => { const weakLines = [ "API_SERVER_KEY=server-key", "API_SERVER_KEY='server-key'", @@ -543,10 +598,9 @@ raise SystemExit(0 if not ok and dashboard_fd is None else 2) const res = runSeed(src, dst, envSrc, envDst); - expect(res.status, weakLine).toBe(1); - expect(res.stderr, weakLine).toContain("API_SERVER_KEY"); + expect(res.status, weakLine).toBe(0); expect(res.stderr, weakLine).not.toContain("server-key"); - expect(fs.existsSync(envDst), weakLine).toBe(false); + expect(fs.readFileSync(envDst, "utf-8"), weakLine).toBe("API_SERVER_HOST=127.0.0.1\n"); } }); @@ -648,7 +702,7 @@ raise SystemExit(0 if not ok and dashboard_fd is None else 2) const dash = readYaml(dst); // Routing overwritten... - expect(dash.model).toEqual(GATEWAY_CONFIG.model); + expect(dash.model).toEqual({ ...GATEWAY_CONFIG.model, provider: "nvidia-router" }); expect(dash.providers).toEqual(GATEWAY_CONFIG.providers); expect(dash.custom_providers).toEqual(GATEWAY_CONFIG.custom_providers); // ...dashboard-local keys preserved. @@ -658,11 +712,12 @@ raise SystemExit(0 if not ok and dashboard_fd is None else 2) dashboard_note: "keep", }); expect(dash.browser).toEqual({ + allow_unsafe_evaluate: false, restrict_evaluate: true, headed: true, }); expect(dash.session_reset).toEqual({ - ...REVIEWED_POLICY.session_reset, + ...EXPECTED_DASHBOARD_POLICY.session_reset, dashboard_scope: "keep", }); expect(dash.display).toEqual({ @@ -685,7 +740,7 @@ raise SystemExit(0 if not ok and dashboard_fd is None else 2) "unexpected session policy field", { session_reset: { - ...REVIEWED_POLICY.session_reset, + ...EXPECTED_DASHBOARD_POLICY.session_reset, dashboard_only: true, }, }, @@ -740,7 +795,7 @@ raise SystemExit(0 if not ok and dashboard_fd is None else 2) expect(res.status).toBe(0); expect(fs.existsSync(dst)).toBe(false); - expect(fs.readFileSync(envDst, "utf-8")).toBe(`API_SERVER_KEY=${GENERATED_HEX_TOKEN}\n`); + expect(fs.readFileSync(envDst, "utf-8")).toBe(""); }); it("fails closed without changing stale dashboard config when gateway routing is absent", () => { @@ -757,7 +812,7 @@ raise SystemExit(0 if not ok and dashboard_fd is None else 2) const res = runSeed(src, dst); expect(res.status).toBe(1); expect(res.stderr).toContain("[SECURITY]"); - expect(res.stderr).toContain("no model routing"); + expect(res.stderr).toContain("invalid model routing"); expect(fs.readFileSync(dst, "utf-8")).toBe(before); });