From f078fe3e4acb4bee231bb08cae912cac41a0c6ab Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 15:16:20 -0400 Subject: [PATCH 01/29] refactor(hermes): centralize managed policy Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 77 +-- agents/hermes/config/generate.ts | 18 +- agents/hermes/config/hermes-config.ts | 272 +---------- agents/hermes/config/managed-policy.ts | 289 +++++++++++ agents/hermes/config/write-config.ts | 8 + agents/hermes/hermes-wrapper.py | 6 +- agents/hermes/image-build-probes.py | 86 ++-- agents/hermes/managed_policy.py | 113 +++++ .../hermes/patch-profile-policy-defaults.py | 325 ++++++------ agents/hermes/seed-dashboard-config.py | 461 +++++------------- agents/hermes/start.sh | 7 + src/lib/actions/inference-route-api.ts | 17 +- .../actions/inference-set-hermes-run.test.ts | 19 + .../inference-set-patch-hermes.test.ts | 25 +- src/lib/actions/inference-set.ts | 22 +- src/lib/hermes-managed-route.ts | 140 ++++++ src/lib/hermes-proxy-api-key.ts | 7 +- src/lib/sandbox/config.ts | 4 +- .../sandbox/hermes-dashboard-reseed.test.ts | 2 + test/generate-hermes-config.test.ts | 12 +- test/hermes-doctor-config-hash.test.ts | 5 +- test/hermes-final-image-layout.test.ts | 7 +- test/hermes-managed-policy.test.ts | 130 +++++ test/hermes-profile-policy-defaults.test.ts | 54 +- test/hermes-start-config-integrity.test.ts | 1 + test/sandbox-provisioning.test.ts | 3 + test/sandbox-rlimit-hooks.test.ts | 3 + test/seed-hermes-dashboard-config.test.ts | 129 +++-- 28 files changed, 1272 insertions(+), 970 deletions(-) create mode 100644 agents/hermes/config/managed-policy.ts create mode 100644 agents/hermes/managed_policy.py create mode 100644 src/lib/hermes-managed-route.ts create mode 100644 test/hermes-managed-policy.test.ts diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 04802acdf35..db9baa12f29 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -36,6 +36,7 @@ COPY agents/hermes/image-build-probes.py /opt/nemoclaw-hermes-config/image-build COPY agents/hermes/patch-gateway-runtime-metadata.py /opt/nemoclaw-hermes-config/patch-gateway-runtime-metadata.py COPY agents/hermes/patch-cron-execution-runtime.py /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.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 @@ -54,6 +55,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 @@ -189,12 +191,12 @@ 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/patch-bundled-npm-tar.mts \ && chmod -R a+rX /src/lib/messaging -ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=b21db6d098920a6bb410cccca4778e2817a57b66307212ef3422e6cafdfd67b4 +ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=b1abbb4324b2147f4beb8af1c7bb9e66c80ec3a3559029800237e8a0bb9d0f91 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256" /opt/nemoclaw-hermes-config/image-build-probes.py \ @@ -224,7 +226,7 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init && 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/lib/nemoclaw/managed-gateway-control.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.85.json \ && chmod 700 /usr/local/bin/nemoclaw-gateway-control \ && chmod 500 /usr/local/lib/nemoclaw/state-dir-guard.py /usr/local/lib/nemoclaw/managed-gateway-control.py \ - && chmod 444 /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py \ + && chmod 444 /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 \ @@ -257,35 +259,6 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/sandbox-init RUN test -x /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py \ || { echo "ERROR: validate-hermes-env-secret-boundary.py missing or not executable" >&2; exit 1; } -# 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 - -# 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 @@ -383,7 +356,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,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=cd851746da14162ac4701d56c274dac20024ea6a11f6ffcf2ce7fb89dff388a0 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=b0343b46fe3898975885dc9bbd4e689f2e2d3e8f4f0b6ff69c45036a710bf891 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 @@ -553,6 +526,40 @@ RUN HERMES_HOME=/sandbox/.hermes /usr/local/bin/hermes doctor --fix \ && node --experimental-strip-types /opt/nemoclaw-hermes-config/generate-config.ts \ && 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=c1e0f66eb6a1a499904cd7b6dbc82913df39133e2b6dc328995c004e8f340933 +# 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 +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 # deliberately exact: a missing leaf or an unexpected session-reset default @@ -561,6 +568,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 \ @@ -884,12 +892,15 @@ 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_metadata /scripts/patch-bundled-npm-brace-expansion.mts 'root:root 444' \ && check_metadata /scripts/patch-bundled-npm-tar.mts 'root:root 444' \ && check_metadata /opt/nemoclaw-hermes-config/generate-config.ts 'root:root 444' \ && 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' \ && check_metadata /usr/local/bin/nemoclaw-gateway-control 'root:root 700' \ && check_metadata /usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root 444' \ && check_metadata /usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755' \ 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 index bca6a171fd5..1519853ad3f 100644 --- a/agents/hermes/config/hermes-config.ts +++ b/agents/hermes/config/hermes-config.ts @@ -2,278 +2,14 @@ // 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"; +import { buildHermesManagedPolicy, finalizeHermesPlatformToolsets } from "./managed-policy.ts"; -const REMOTE_PLATFORM_TOOLSETS = [ - "web", - "browser", - "terminal", - "file", - "code_execution", - "vision", - "image_gen", - "skills", - "todo", - "memory", - "session_search", - "delegation", - "cronjob", - "nemoclaw", - "audio", -]; - -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 { finalizeHermesPlatformToolsets }; +/** Return the primary-home configuration from the managed Hermes policy model. */ 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 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: { - api_server: { - enabled: true, - extra: { - port: 18642, - host: "127.0.0.1", - }, - }, - }, - }; - - 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; + return buildHermesManagedPolicy(settings, env).config; } diff --git a/agents/hermes/config/managed-policy.ts b/agents/hermes/config/managed-policy.ts new file mode 100644 index 00000000000..9210945cf41 --- /dev/null +++ b/agents/hermes/config/managed-policy.ts @@ -0,0 +1,289 @@ +// 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", +]; + +const DASHBOARD_ROUTING_KEYS = [ + "model", + "providers", + "custom_providers", + "_nemoclaw_upstream", +] as const; + +const DASHBOARD_ENV_KEYS = [ + "API_SERVER_HOST", + "API_SERVER_PORT", + "API_SERVER_KEY", + "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 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: { + api_server: { + enabled: true, + extra: { + port: 18642, + host: "127.0.0.1", + }, + }, + }, + }; + + 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.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/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 bb844b7bd52..0371fa2e639 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -34,10 +34,8 @@ # `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 diff --git a/agents/hermes/image-build-probes.py b/agents/hermes/image-build-probes.py index 9c1d41b1527..32e0bea22ca 100644 --- a/agents/hermes/image-build-probes.py +++ b/agents/hermes/image-build-probes.py @@ -9,6 +9,14 @@ from collections.abc import Callable from pathlib import Path +sys.path.insert(0, "/usr/local/lib/nemoclaw") + +from managed_policy import ( # noqa: E402 + load_managed_policy, + policy_value, + profile_default_values, +) + def verify_profile_policy() -> None: from types import SimpleNamespace @@ -24,32 +32,21 @@ def verify_profile_policy() -> None: ) 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" + for path, value in expected.items(): + assert policy_value(config, path) == value, (path, policy_value(config, path), value) + 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"] + assert SessionResetPolicy().mode == expected["session_reset.mode"] + assert SessionResetPolicy.from_dict({}).mode == expected["session_reset.mode"] 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 + assert gateway.default_reset_policy.mode == expected["session_reset.mode"] + assert gateway.default_reset_policy.at_hour == expected["session_reset.at_hour"] + assert gateway.default_reset_policy.idle_minutes == expected["session_reset.idle_minutes"] original_load_config = hermes_config.load_config try: @@ -58,16 +55,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: @@ -234,28 +229,11 @@ def verify_dashboard_policy(path: Path) -> None: import yaml 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..fd473663d72 --- /dev/null +++ b/agents/hermes/managed_policy.py @@ -0,0 +1,113 @@ +# 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 + + +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"] + for path in managed_paths: + policy_value(config, 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..152a73acdaf 100755 --- a/agents/hermes/patch-profile-policy-defaults.py +++ b/agents/hermes/patch-profile-policy-defaults.py @@ -35,9 +35,19 @@ 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, + load_managed_policy, + profile_default_values, +) + EXPECTED_SOURCE_SHA256 = { "config": "172b78ecb923048859ca177d96f5b010b44ec74bb1d13553577ff49bde1a071d", "browser": "02b4a0a0c8fc8b204c8f818dff1dd64295a817e5543b8a643198bcedbfbbcba2", @@ -48,130 +58,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 +95,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 +103,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 +215,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 +249,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 +292,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 +300,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 +342,15 @@ 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") + values = profile_default_values(load_managed_policy(args.policy)) + + 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 f52353d0876..e8ff59309f4 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,8 +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 + seed-dashboard-config.py 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 @@ -55,50 +51,19 @@ 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", - } +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from managed_policy import ( # noqa: E402 + ManagedPolicyError, + load_managed_policy, + policy_value, ) + API_SERVER_KEY_RE = re.compile(r"^[0-9a-f]{64}$") -TAVILY_API_KEY_PLACEHOLDER = "openshell:resolve:env:TAVILY_API_KEY" class UnsafeDashboardSeedPathError(Exception): @@ -109,10 +74,6 @@ class MissingDashboardSeedPathError(Exception): pass -class InvalidDashboardPolicyError(Exception): - pass - - class InvalidDashboardSeedDocumentError(Exception): pass @@ -233,287 +194,81 @@ def _atomic_write_no_follow(dst: str, label: str, writer: Callable[[TextIO], Non pass -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] - +def _normalized_routing(gateway: dict, routing_keys: list[str]) -> 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(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 InvalidDashboardSeedDocumentError("gateway config has invalid model routing") + model["provider"] = provider_key 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") - if ( - not isinstance(excluded_platforms, list) - or not excluded_platforms - or not all(isinstance(value, str) and value for value in excluded_platforms) - ): - raise InvalidDashboardPolicyError( - "session_reset.notify_exclude_platforms must be a non-empty list of strings" +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() ) - bg_process_max_age_hours = _policy_int( - session_reset, - "session_reset", - "bg_process_max_age_hours", - 1, - 2**31 - 1, - ) - - 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" + 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) ) - 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, - }, - } + return actual == expected def _merge_policy(dashboard: dict, policy: dict) -> None: @@ -525,7 +280,7 @@ def _merge_policy(dashboard: dict, policy: dict) -> None: dashboard[section_name] = merged -def _mirror_env(src: str, dst: str) -> bool: +def _mirror_env(src: str, dst: str, policy: dict) -> bool: try: env_text = _read_regular_text_no_follow(src, "gateway env") except MissingDashboardSeedPathError: @@ -556,13 +311,20 @@ 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( @@ -571,7 +333,7 @@ def parse_env_assignment(line: str) -> tuple[str, str] | None: 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", @@ -592,15 +354,16 @@ def write_env(dst_handle: TextIO) -> None: def main(argv: list[str]) -> int: - if len(argv) not in (3, 5): + if len(argv) not in (4, 6): print( "[dashboard] usage: seed-dashboard-config.py " - " [ ]", + " " + "[ ]", file=sys.stderr, ) return 1 - src, dst = argv[1], argv[2] + policy_path, src, dst = argv[1], argv[2], argv[3] try: import yaml # noqa: F401 @@ -611,6 +374,15 @@ def main(argv: list[str]) -> 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: @@ -618,8 +390,8 @@ def main(argv: list[str]) -> 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]) + if len(argv) == 6: + env_ok = _mirror_env(argv[4], argv[5], policy) return 0 if env_ok else 1 except UnsafeDashboardSeedPathError as exc: print(f"[SECURITY] Refusing to seed dashboard config because {exc}", file=sys.stderr) @@ -633,16 +405,17 @@ def main(argv: list[str]) -> 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"]) + except InvalidDashboardSeedDocumentError: print( "[SECURITY] Refusing to seed dashboard config because gateway config has no 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, @@ -670,12 +443,12 @@ def main(argv: list[str]) -> 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]): + if len(argv) == 6 and not _mirror_env(argv[4], argv[5], policy): 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" @@ -686,7 +459,7 @@ def main(argv: list[str]) -> int: else: dashboard.pop("web", None) dashboard.update(routing) - _merge_policy(dashboard, policy) + _merge_policy(dashboard, policy_sections) import yaml diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 81b3a7a02d4..41a1e280899 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -226,6 +226,10 @@ _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" +if [ ! -f "$_HERMES_MANAGED_POLICY" ]; then + _HERMES_MANAGED_POLICY="${HERMES_DIR}/managed-policy.json" +fi # Descriptor-safe updater for runtime-mutable Hermes config/env/hash files. _HERMES_RUNTIME_CONFIG_GUARD="/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py" @@ -1472,6 +1476,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 @@ -1489,6 +1494,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=$? @@ -1529,6 +1535,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=$? diff --git a/src/lib/actions/inference-route-api.ts b/src/lib/actions/inference-route-api.ts index 0512f6bd85e..1a86b3e7c52 100644 --- a/src/lib/actions/inference-route-api.ts +++ b/src/lib/actions/inference-route-api.ts @@ -1,11 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { hermesApiMode } from "../hermes-managed-route"; import { getSandboxInferenceConfig, resolveAgentInferenceApi } from "../inference/config"; import type { ConfigObject } from "../security/credential-filter"; import { isConfigObject } from "../security/credential-filter"; import type { Session } from "../state/onboard-session"; +export { hermesApiMode }; + export type InferenceApi = "openai-completions" | "anthropic-messages" | "openai-responses"; const SUPPORTED_INFERENCE_APIS = new Set([ @@ -125,17 +128,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..16f87f8ecf1 100644 --- a/src/lib/actions/inference-set-hermes-run.test.ts +++ b/src/lib/actions/inference-set-hermes-run.test.ts @@ -57,14 +57,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_API_KEY_PLACEHOLDER, + 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, }, + providers: { + "hermes-provider": { + name: "hermes-provider", + api: "https://inference.local/v1", + api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + default_model: "openai/gpt-5.4-mini", + discover_models: true, + }, + }, terminal: { backend: "local" }, }); expect(deps.calls.writeSandboxConfig).toHaveBeenCalledTimes(1); @@ -364,6 +382,7 @@ describe("runInferenceSet Hermes routing", () => { // 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([ diff --git a/src/lib/actions/inference-set-patch-hermes.test.ts b/src/lib/actions/inference-set-patch-hermes.test.ts index 41cfdf5d27e..37f7fa41708 100644 --- a/src/lib/actions/inference-set-patch-hermes.test.ts +++ b/src/lib/actions/inference-set-patch-hermes.test.ts @@ -7,7 +7,7 @@ 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", @@ -33,8 +33,29 @@ describe("patchHermesInferenceConfig", () => { provider: "custom", base_url: "https://inference.local/v1", api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, - temperature: 0.2, }); + 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_API_KEY_PLACEHOLDER, + 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_API_KEY_PLACEHOLDER, + discover_models: true, + }, + ]); expect(config.models).toEqual({ providers: { inference: { diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 5c56e0906d5..220836ed553 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, @@ -541,20 +541,12 @@ export function patchHermesInferenceConfig( ): { 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, + }); return { changed: before !== JSON.stringify(config), route }; } diff --git a/src/lib/hermes-managed-route.ts b/src/lib/hermes-managed-route.ts new file mode 100644 index 00000000000..00ccbcf149e --- /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_API_KEY_PLACEHOLDER = "sk-OPENSHELL-PROXY-REWRITE"; + +type HermesManagedProvider = { + name: string; + api_key: typeof HERMES_PROXY_API_KEY_PLACEHOLDER; + 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_API_KEY_PLACEHOLDER; + 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_API_KEY_PLACEHOLDER, + }; + 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_API_KEY_PLACEHOLDER, + 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_API_KEY_PLACEHOLDER, + 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 index 1d4449ce359..a5e2ed68b33 100644 --- a/src/lib/hermes-proxy-api-key.ts +++ b/src/lib/hermes-proxy-api-key.ts @@ -1,9 +1,4 @@ // 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"; +export { HERMES_PROXY_API_KEY_PLACEHOLDER } from "./hermes-managed-route"; diff --git a/src/lib/sandbox/config.ts b/src/lib/sandbox/config.ts index ae4a273c8c1..3ad9f7a0279 100644 --- a/src/lib/sandbox/config.ts +++ b/src/lib/sandbox/config.ts @@ -648,6 +648,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", @@ -784,6 +785,7 @@ function seedHermesDashboardConfig( const seed = capture([ python, HERMES_DASHBOARD_SEEDER_PATH, + HERMES_MANAGED_POLICY_PATH, target.configPath, dashboardConfigPath, `${target.configDir}/.env`, @@ -1514,11 +1516,11 @@ export { buildConfigSetRestartGuidance, buildRecomputeSandboxConfigHashScript, classifyNewKeyGate, - configSetAllowsOpenShellBridge, composeSandboxConfigBody, configGet, configRotateToken, configSet, + configSetAllowsOpenShellBridge, DEFAULT_AGENT_CONFIG, extractDotpath, findClobberingAncestor, diff --git a/src/lib/sandbox/hermes-dashboard-reseed.test.ts b/src/lib/sandbox/hermes-dashboard-reseed.test.ts index 0fa3dfea8c5..3f6372352f3 100644 --- a/src/lib/sandbox/hermes-dashboard-reseed.test.ts +++ b/src/lib/sandbox/hermes-dashboard-reseed.test.ts @@ -23,6 +23,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/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>(); @@ -106,6 +107,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)/dashboard-home/config.yaml", "/sandbox/Hermes home;$(touch dir-pwned)/.env", diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 7bc9c21a8af..480146f2863 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -239,6 +239,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; } @@ -402,7 +406,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", @@ -505,6 +512,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", }); }); @@ -695,6 +703,7 @@ describe("agents/hermes/generate-config.ts", () => { }); 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(); @@ -772,6 +781,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, diff --git a/test/hermes-doctor-config-hash.test.ts b/test/hermes-doctor-config-hash.test.ts index ff32a19fabf..263d6af3117 100644 --- a/test/hermes-doctor-config-hash.test.ts +++ b/test/hermes-doctor-config-hash.test.ts @@ -76,6 +76,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", @@ -99,6 +100,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"), @@ -152,6 +154,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"); @@ -255,7 +258,7 @@ describe("Hermes doctor and config hash boundary", () => { expect([mode(configPath), mode(envPath)]).toEqual(["640", "640"]); const hash = runDockerShell(hashCommand, sandboxRoot); - expect(hash.result.status).toBe(0); + expect(hash.result.status, hash.result.stderr).toBe(0); expect(hash.result.stderr).toBe(""); expect(mode(path.join(etcDir, "hermes.config-hash"))).toBe("444"); const verifyHash = spawnSync("sha256sum", ["-c", path.join(etcDir, "hermes.config-hash")], { diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 6b6be64be86..afb73b56021 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -216,6 +216,7 @@ describe("Hermes final image layout", () => { "COPY agents/hermes/patch-gateway-runtime-metadata.py /opt/nemoclaw-hermes-config/patch-gateway-runtime-metadata.py", "COPY agents/hermes/patch-cron-execution-runtime.py /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.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", @@ -236,6 +237,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", @@ -334,6 +336,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-gateway-control 'root:root 700'", "/usr/local/lib/nemoclaw/preloads/sandbox-safety-net.js 'root:root 444'", "/usr/local/lib/nemoclaw/hermes-wrapper.py 'root:root 755'", @@ -352,6 +356,7 @@ describe("Hermes final image layout", () => { "&& check_absent /opt/nemoclaw-hermes-config/image-build-probes.py \\", ); expect(finalStage).toContain("&& check_absent /sandbox/.cache \\"); + expect(finalStage).toContain("&& check_absent /sandbox/.hermes/managed-policy.json \\"); }); // source-shape-contract: security -- Exact source-to-image digests keep the reviewed Hermes runtime entrypoints bound to the files copied into the sandbox image @@ -395,7 +400,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-managed-policy.test.ts b/test/hermes-managed-policy.test.ts new file mode 100644 index 00000000000..3889f6e3ec9 --- /dev/null +++ b/test/hermes-managed-policy.test.ts @@ -0,0 +1,130 @@ +// 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: [] }, +}; + +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(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("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..f8f8858e190 100644 --- a/test/hermes-profile-policy-defaults.test.ts +++ b/test/hermes-profile-policy-defaults.test.ts @@ -4,9 +4,12 @@ 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"); @@ -15,6 +18,19 @@ 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: [] }, +}; +const MANAGED_POLICY = buildHermesManagedPolicy(POLICY_SETTINGS, {}); const configFixture = `\ DEFAULT_CONFIG = { @@ -117,21 +133,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 +218,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 +229,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([ @@ -254,14 +279,9 @@ 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(imageBuildProbes).toContain("expected = profile_default_values(policy)"); + expect(imageBuildProbes).toContain("for path, value in expected.items()"); + expect(imageBuildProbes).not.toContain('config["approvals"]["mode"] == "manual"'); }); }); diff --git a/test/hermes-start-config-integrity.test.ts b/test/hermes-start-config-integrity.test.ts index 5f9e2e246d1..0f39da8ffb5 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`, diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 1b1769b0b93..65632f1cd0f 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 managedGatewayControlPath = path.join(localLib, "managed-gateway-control.py"); @@ -1123,6 +1124,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"), @@ -1163,6 +1165,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(managedGatewayControlPath).mode & 0o777).toString(8)).toBe("500"); diff --git a/test/sandbox-rlimit-hooks.test.ts b/test/sandbox-rlimit-hooks.test.ts index 0b8fbb74e40..248b34d1448 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"); @@ -589,6 +590,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"); @@ -631,6 +633,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 0763616e024..7e907e6c2bb 100644 --- a/test/seed-hermes-dashboard-config.test.ts +++ b/test/seed-hermes-dashboard-config.test.ts @@ -18,6 +18,11 @@ 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, + type HermesManagedPolicyV1, +} from "../agents/hermes/config/managed-policy.ts"; const SCRIPT_PATH = path.join( import.meta.dirname, @@ -36,30 +41,61 @@ 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 = { - 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, - }, +const POLICY_SETTINGS: HermesBuildSettings = { + model: "nvidia-routed", + baseUrl: "https://inference.local/v1", + providerKey: "nvidia-router", + upstreamProvider: "nvidia-router", + inferenceApi: "openai-completions", + contextWindow: null, + toolDisclosure: "progressive", + webSearchProvider: "tavily", + messagingCredentialPlaceholders: [], + managedToolGateways: { brokerEnabled: false, presets: [] }, }; +const MANAGED_POLICY = buildHermesManagedPolicy(POLICY_SETTINGS, {}); +const REVIEWED_POLICY = projectManagedPolicy(MANAGED_POLICY); +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]], + ), +); + +function projectManagedPolicy( + policy: HermesManagedPolicyV1, +): Record> { + const projected: Record> = {}; + for (const dottedPath of policy.managed_paths) { + const segments = dottedPath.split("."); + let source: unknown = policy.config; + let target: Record = projected; + for (const [index, segment] of segments.entries()) { + if (typeof source !== "object" || source === null || Array.isArray(source)) { + throw new Error(`Invalid managed policy path: ${dottedPath}`); + } + source = (source as Record)[segment]; + if (index === segments.length - 1) { + target[segment] = structuredClone(source); + } else { + const child = target[segment]; + if (typeof child !== "object" || child === null || Array.isArray(child)) { + target[segment] = {}; + } + target = target[segment] as Record; + } + } + } + return projected; +} 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", @@ -86,10 +122,12 @@ const GATEWAY_CONFIG = { // 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, @@ -99,7 +137,7 @@ function runSeed( env: Record = {}, ) { const envArgs = envSrcPath && envDstPath ? [envSrcPath, envDstPath] : []; - const args = [SCRIPT_PATH, srcPath, dstPath, ...envArgs]; + const args = [SCRIPT_PATH, policyPath, srcPath, dstPath, ...envArgs]; return spawnSync("python3", args, { encoding: "utf-8", env: { ...process.env, ...env }, @@ -125,6 +163,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(() => { @@ -163,21 +203,19 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { 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" }, @@ -195,30 +233,16 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { discover_models: true, }, ], + web: { backend: "tavily" }, ...REVIEWED_POLICY, }; const src = writeYaml("gw.yaml", legacy); const dst = path.join(tmpDir, "dash.yaml"); const res = runSeed(src, dst); - expect(res.status).toBe(0); - - 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, - }, - }); + expect(res.status).toBe(1); + expect(res.stderr).toContain("no model routing"); + expect(fs.existsSync(dst)).toBe(false); }); it("mirrors only dashboard-needed gateway .env keys for Hermes 0.16 chat setup", () => { @@ -415,6 +439,7 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { dashboard_note: "keep", }); expect(dash.browser).toEqual({ + allow_unsafe_evaluate: false, restrict_evaluate: true, headed: true, }); From 3e17fdb9e567cc91d0967a3f8535a83279086865 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 15:22:43 -0400 Subject: [PATCH 02/29] test(hermes): keep policy fixture linear Signed-off-by: Julie Yaunches --- test/seed-hermes-dashboard-config.test.ts | 36 +++++++++-------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/test/seed-hermes-dashboard-config.test.ts b/test/seed-hermes-dashboard-config.test.ts index 7e907e6c2bb..d5e5e2edca7 100644 --- a/test/seed-hermes-dashboard-config.test.ts +++ b/test/seed-hermes-dashboard-config.test.ts @@ -65,28 +65,20 @@ const GATEWAY_POLICY = Object.fromEntries( function projectManagedPolicy( policy: HermesManagedPolicyV1, ): Record> { - const projected: Record> = {}; - for (const dottedPath of policy.managed_paths) { - const segments = dottedPath.split("."); - let source: unknown = policy.config; - let target: Record = projected; - for (const [index, segment] of segments.entries()) { - if (typeof source !== "object" || source === null || Array.isArray(source)) { - throw new Error(`Invalid managed policy path: ${dottedPath}`); - } - source = (source as Record)[segment]; - if (index === segments.length - 1) { - target[segment] = structuredClone(source); - } else { - const child = target[segment]; - if (typeof child !== "object" || child === null || Array.isArray(child)) { - target[segment] = {}; - } - target = target[segment] as Record; - } - } - } - return projected; + const leaves = policy.managed_paths.map((dottedPath) => { + const [section, key] = dottedPath.split("."); + const source = policy.config[section] as Record; + return { section, key, value: structuredClone(source[key]) }; + }); + const sections = new Set(leaves.map(({ section }) => section)); + return Object.fromEntries( + Array.from(sections, (section) => [ + section, + Object.fromEntries( + leaves.filter((leaf) => leaf.section === section).map(({ key, value }) => [key, value]), + ), + ]), + ); } const GATEWAY_CONFIG = { From 858977d4d0a9bd583b301b4e091c27102d506dbf Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 15:45:26 -0400 Subject: [PATCH 03/29] fix(hermes): seal runtime managed policy Signed-off-by: Julie Yaunches --- .../references/hermes-contract-map.md | 2 +- agents/hermes/Dockerfile | 4 +- agents/hermes/config/hermes-config.ts | 15 ----- agents/hermes/config/managed-policy.ts | 3 +- agents/hermes/hermes-wrapper.py | 2 +- agents/hermes/managed_policy.py | 4 +- .../hermes/patch-profile-policy-defaults.py | 6 +- agents/hermes/start.sh | 3 - docs/inference/model-capability-audit.mdx | 2 +- src/lib/actions/inference-route-api.test.ts | 2 +- src/lib/actions/inference-route-api.ts | 3 - .../actions/inference-set-hermes-run.test.ts | 2 +- .../inference-set-patch-hermes.test.ts | 2 +- src/lib/actions/inference-set.ts | 4 +- src/lib/hermes-proxy-api-key.ts | 4 -- .../managed-startup-image-runtime.test.ts | 34 ++++++++++ .../onboard/managed-startup/image-runtime.ts | 18 +++++ test/generate-hermes-config.test.ts | 6 +- test/hermes-dependency-review.test.ts | 2 +- test/hermes-gateway-wrapper.test.ts | 8 +-- test/hermes-profile-policy-defaults.test.ts | 23 +++++-- test/hermes-start-config-integrity.test.ts | 4 +- test/hermes-upgrade-skill.test.ts | 2 +- test/seed-hermes-dashboard-config.test.ts | 66 +++++++++---------- 24 files changed, 128 insertions(+), 93 deletions(-) delete mode 100644 agents/hermes/config/hermes-config.ts delete mode 100644 src/lib/hermes-proxy-api-key.ts diff --git a/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md b/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md index ee858f3d6fc..5ba686000af 100644 --- a/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md +++ b/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md @@ -34,7 +34,7 @@ The complete Hermes range is the union of the generic evidence and every adjacen Audit these surfaces: -- `agents/hermes/config/hermes-config.ts`; +- `agents/hermes/config/managed-policy.ts`; - `test/generate-hermes-config.test.ts`; - config generation and `hermes doctor --fix` order in `agents/hermes/Dockerfile`; - upstream `DEFAULT_CONFIG`, migrations, validation, and config-loading precedence. diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index db9baa12f29..d1e011ca90d 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -356,7 +356,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,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=b0343b46fe3898975885dc9bbd4e689f2e2d3e8f4f0b6ff69c45036a710bf891 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=5ec091796bc02be4a2bba2c76315b499b513229480e8db038cc4b31ea81aa764 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 @@ -537,7 +537,7 @@ RUN install -o root -g root -m 0444 \ # 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=c1e0f66eb6a1a499904cd7b6dbc82913df39133e2b6dc328995c004e8f340933 +ARG NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256=c4e9fb99d3fcf432cec829a126c15188fc799091c57a14834f3bd9a3edb63e6f # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256" /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py \ diff --git a/agents/hermes/config/hermes-config.ts b/agents/hermes/config/hermes-config.ts deleted file mode 100644 index 1519853ad3f..00000000000 --- a/agents/hermes/config/hermes-config.ts +++ /dev/null @@ -1,15 +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 { buildHermesManagedPolicy, finalizeHermesPlatformToolsets } from "./managed-policy.ts"; - -export { finalizeHermesPlatformToolsets }; - -/** Return the primary-home configuration from the managed Hermes policy model. */ -export function buildHermesConfig( - settings: HermesBuildSettings, - env: NodeJS.ProcessEnv = process.env, -): Record { - return buildHermesManagedPolicy(settings, env).config; -} diff --git a/agents/hermes/config/managed-policy.ts b/agents/hermes/config/managed-policy.ts index 9210945cf41..98363559a89 100644 --- a/agents/hermes/config/managed-policy.ts +++ b/agents/hermes/config/managed-policy.ts @@ -264,8 +264,7 @@ function buildHermesRemotePlatformToolsets(settings: HermesBuildSettings): strin const remotePlatformToolsets = [...REMOTE_PLATFORM_TOOLSETS]; if ( settings.managedToolGateways.brokerEnabled && - settings.managedToolGateways.presets.includes("nous-audio") && - !remotePlatformToolsets.includes("tts") + settings.managedToolGateways.presets.includes("nous-audio") ) { remotePlatformToolsets.push("tts"); } diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 0371fa2e639..92cb19f77f9 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -29,7 +29,7 @@ # 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 +# `agents/hermes/config/managed-policy.ts:buildHermesManagedPolicy`, the literal # placeholder `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 diff --git a/agents/hermes/managed_policy.py b/agents/hermes/managed_policy.py index fd473663d72..099363f2b3c 100644 --- a/agents/hermes/managed_policy.py +++ b/agents/hermes/managed_policy.py @@ -88,8 +88,8 @@ def load_managed_policy(path: Path = MANAGED_POLICY_PATH) -> dict: "managed policy managed_paths", ) config = document["config"] - for path in managed_paths: - policy_value(config, path) + 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}") diff --git a/agents/hermes/patch-profile-policy-defaults.py b/agents/hermes/patch-profile-policy-defaults.py index 152a73acdaf..d450d8aa459 100755 --- a/agents/hermes/patch-profile-policy-defaults.py +++ b/agents/hermes/patch-profile-policy-defaults.py @@ -44,6 +44,7 @@ from managed_policy import ( # noqa: E402 MANAGED_POLICY_PATH, + ManagedPolicyError, load_managed_policy, profile_default_values, ) @@ -342,7 +343,10 @@ def main() -> int: help="Pinned Hermes main/update module", ) args = parser.parse_args() - values = profile_default_values(load_managed_policy(args.policy)) + 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) diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 41a1e280899..63d89282042 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -227,9 +227,6 @@ 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" -if [ ! -f "$_HERMES_MANAGED_POLICY" ]; then - _HERMES_MANAGED_POLICY="${HERMES_DIR}/managed-policy.json" -fi # Descriptor-safe updater for runtime-mutable Hermes config/env/hash files. _HERMES_RUNTIME_CONFIG_GUARD="/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py" 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/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 1a86b3e7c52..9f9c256a097 100644 --- a/src/lib/actions/inference-route-api.ts +++ b/src/lib/actions/inference-route-api.ts @@ -1,14 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { hermesApiMode } from "../hermes-managed-route"; import { getSandboxInferenceConfig, resolveAgentInferenceApi } from "../inference/config"; import type { ConfigObject } from "../security/credential-filter"; import { isConfigObject } from "../security/credential-filter"; import type { Session } from "../state/onboard-session"; -export { hermesApiMode }; - export type InferenceApi = "openai-completions" | "anthropic-messages" | "openai-responses"; const SUPPORTED_INFERENCE_APIS = new Set([ diff --git a/src/lib/actions/inference-set-hermes-run.test.ts b/src/lib/actions/inference-set-hermes-run.test.ts index 16f87f8ecf1..4c31521fe67 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_API_KEY_PLACEHOLDER } 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"; diff --git a/src/lib/actions/inference-set-patch-hermes.test.ts b/src/lib/actions/inference-set-patch-hermes.test.ts index 37f7fa41708..8453b4b61cc 100644 --- a/src/lib/actions/inference-set-patch-hermes.test.ts +++ b/src/lib/actions/inference-set-patch-hermes.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_API_KEY_PLACEHOLDER } from "../hermes-managed-route"; import type { ConfigObject } from "../security/credential-filter"; import { patchHermesInferenceConfig } from "./inference-set"; diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 220836ed553..8fb7e3bae76 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 { applyHermesManagedRoute } from "../hermes-managed-route"; +import { applyHermesManagedRoute, hermesApiMode } 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, diff --git a/src/lib/hermes-proxy-api-key.ts b/src/lib/hermes-proxy-api-key.ts deleted file mode 100644 index a5e2ed68b33..00000000000 --- a/src/lib/hermes-proxy-api-key.ts +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -export { HERMES_PROXY_API_KEY_PLACEHOLDER } from "./hermes-managed-route"; diff --git a/src/lib/onboard/managed-startup-image-runtime.test.ts b/src/lib/onboard/managed-startup-image-runtime.test.ts index e7e2d0e6359..b12b0ed0911 100644 --- a/src/lib/onboard/managed-startup-image-runtime.test.ts +++ b/src/lib/onboard/managed-startup-image-runtime.test.ts @@ -22,6 +22,7 @@ import { applyManagedStartupImageProfile, applyManagedStartupRootRequest, buildManagedStartupImageActionPlan, + installHermesManagedPolicy, MANAGED_STARTUP_COMPLETION_FILE, MANAGED_STARTUP_MERGED_CA_FILE, MANAGED_STARTUP_PROFILE_ENV, @@ -842,6 +843,39 @@ describe("managed startup image runtime", () => { expect(() => readStableRegularFile(target, 1024)).toThrow(/changed while it was read/u); }); + it("promotes generated Hermes policy to one immutable runtime artifact", () => { + const directory = temporaryDirectory(); + 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 }); + 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 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); + + 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("normalizes mutable sandbox-owned Hermes config descriptors to mode 0640", () => { const directory = temporaryDirectory(); const target = path.join(directory, "config.yaml"); diff --git a/src/lib/onboard/managed-startup/image-runtime.ts b/src/lib/onboard/managed-startup/image-runtime.ts index c6252ad97c2..880f4c26282 100644 --- a/src/lib/onboard/managed-startup/image-runtime.ts +++ b/src/lib/onboard/managed-startup/image-runtime.ts @@ -56,6 +56,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; const MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION = 1; @@ -806,6 +809,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 @@ -1286,6 +1303,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/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 480146f2863..adbe4ac3685 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -12,9 +12,9 @@ import { readHermesBuildSettings, } from "../agents/hermes/config/build-env.ts"; import { generateHermesConfig } from "../agents/hermes/config/generate.ts"; -import { buildHermesConfig } from "../agents/hermes/config/hermes-config.ts"; +import { buildHermesManagedPolicy } 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_API_KEY_PLACEHOLDER } from "../src/lib/hermes-managed-route"; import { applyCompatibleEndpointContextWindow, resetCompatibleEndpointContextWindowAutoState, @@ -560,7 +560,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, diff --git a/test/hermes-dependency-review.test.ts b/test/hermes-dependency-review.test.ts index 8273c2aa0c7..81bd4393e9c 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-gateway-wrapper.test.ts b/test/hermes-gateway-wrapper.test.ts index ddfce3a232f..c9cfaad8f72 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", @@ -1059,7 +1059,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 } }); @@ -1107,7 +1107,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-profile-policy-defaults.test.ts b/test/hermes-profile-policy-defaults.test.ts index f8f8858e190..14ea7a9b7f0 100644 --- a/test/hermes-profile-policy-defaults.test.ts +++ b/test/hermes-profile-policy-defaults.test.ts @@ -14,10 +14,6 @@ import { buildHermesManagedPolicy } from "../agents/hermes/config/managed-policy const root = path.join(import.meta.dirname, ".."); const patcher = path.join(root, "agents", "hermes", "patch-profile-policy-defaults.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", @@ -258,6 +254,21 @@ 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("hash-binds the reviewed source patch and probes a real config-less profile", () => { const digest = createHash("sha256").update(fs.readFileSync(patcher)).digest("hex"); @@ -280,8 +291,6 @@ 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(dockerfile).toContain("/usr/local/share/nemoclaw/hermes-managed-policy.json"); - expect(imageBuildProbes).toContain("expected = profile_default_values(policy)"); - expect(imageBuildProbes).toContain("for path, value in expected.items()"); - expect(imageBuildProbes).not.toContain('config["approvals"]["mode"] == "manual"'); + 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 0f39da8ffb5..e0e651ed6fc 100644 --- a/test/hermes-start-config-integrity.test.ts +++ b/test/hermes-start-config-integrity.test.ts @@ -204,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/hermes-upgrade-skill.test.ts b/test/hermes-upgrade-skill.test.ts index d6d991ece6d..3414462e278 100644 --- a/test/hermes-upgrade-skill.test.ts +++ b/test/hermes-upgrade-skill.test.ts @@ -78,7 +78,7 @@ describe("Hermes upgrade skill", () => { it("maps semantic, state, packaging, and historical contracts", () => { for (const expected of [ - "agents/hermes/config/hermes-config.ts", + "agents/hermes/config/managed-policy.ts", "agents/hermes/hermes-wrapper.py", "agents/hermes/patch-session-list-preview.py", "agents/hermes/patch-langfuse-credentials.mts", diff --git a/test/seed-hermes-dashboard-config.test.ts b/test/seed-hermes-dashboard-config.test.ts index d5e5e2edca7..86a627ad2fa 100644 --- a/test/seed-hermes-dashboard-config.test.ts +++ b/test/seed-hermes-dashboard-config.test.ts @@ -19,10 +19,7 @@ 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, - type HermesManagedPolicyV1, -} from "../agents/hermes/config/managed-policy.ts"; +import { buildHermesManagedPolicy } from "../agents/hermes/config/managed-policy.ts"; const SCRIPT_PATH = path.join( import.meta.dirname, @@ -46,7 +43,7 @@ const POLICY_SETTINGS: HermesBuildSettings = { baseUrl: "https://inference.local/v1", providerKey: "nvidia-router", upstreamProvider: "nvidia-router", - inferenceApi: "openai-completions", + inferenceApi: "anthropic-messages", contextWindow: null, toolDisclosure: "progressive", webSearchProvider: "tavily", @@ -54,7 +51,20 @@ const POLICY_SETTINGS: HermesBuildSettings = { managedToolGateways: { brokerEnabled: false, presets: [] }, }; const MANAGED_POLICY = buildHermesManagedPolicy(POLICY_SETTINGS, {}); -const REVIEWED_POLICY = projectManagedPolicy(MANAGED_POLICY); +const EXPECTED_DASHBOARD_POLICY = { + approvals: { mode: "manual" }, + browser: { allow_unsafe_evaluate: false, 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 }, +}; const GATEWAY_POLICY = Object.fromEntries( Array.from( new Set(MANAGED_POLICY.managed_paths.map((path) => path.split(".", 1)[0])), @@ -62,25 +72,6 @@ const GATEWAY_POLICY = Object.fromEntries( ), ); -function projectManagedPolicy( - policy: HermesManagedPolicyV1, -): Record> { - const leaves = policy.managed_paths.map((dottedPath) => { - const [section, key] = dottedPath.split("."); - const source = policy.config[section] as Record; - return { section, key, value: structuredClone(source[key]) }; - }); - const sections = new Set(leaves.map(({ section }) => section)); - return Object.fromEntries( - Array.from(sections, (section) => [ - section, - Object.fromEntries( - leaves.filter((leaf) => leaf.section === section).map(({ key, value }) => [key, value]), - ), - ]), - ); -} - const GATEWAY_CONFIG = { _config_version: 12, _nemoclaw_upstream: { @@ -90,9 +81,10 @@ const GATEWAY_CONFIG = { }, 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": { @@ -101,6 +93,7 @@ const GATEWAY_CONFIG = { api_key: "sk-OPENSHELL-PROXY-REWRITE", default_model: "nvidia-routed", discover_models: true, + transport: "anthropic_messages", }, }, custom_providers: [ @@ -109,6 +102,7 @@ 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 @@ -171,15 +165,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("mirrors only the exact native Tavily backend into dashboard config", () => { @@ -226,7 +220,7 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { }, ], web: { backend: "tavily" }, - ...REVIEWED_POLICY, + ...GATEWAY_POLICY, }; const src = writeYaml("gw.yaml", legacy); const dst = path.join(tmpDir, "dash.yaml"); @@ -421,7 +415,7 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { 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. @@ -436,7 +430,7 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { headed: true, }); expect(dash.session_reset).toEqual({ - ...REVIEWED_POLICY.session_reset, + ...EXPECTED_DASHBOARD_POLICY.session_reset, dashboard_scope: "keep", }); expect(dash.display).toEqual({ @@ -459,7 +453,7 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { "unexpected session policy field", { session_reset: { - ...REVIEWED_POLICY.session_reset, + ...EXPECTED_DASHBOARD_POLICY.session_reset, dashboard_only: true, }, }, From f72209224da365ef613b2b36041b459b4319655b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 15:49:50 -0400 Subject: [PATCH 04/29] docs(hermes): align policy references Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- agents/hermes/hermes-wrapper.py | 8 ++++---- src/lib/onboard/managed-startup-image-runtime.test.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index d1e011ca90d..3b93d986e4d 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -356,7 +356,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,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=5ec091796bc02be4a2bba2c76315b499b513229480e8db038cc4b31ea81aa764 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=3bef63b35e7704906241166b87d1256d806d45d10d32415f1e418a5375aa4f22 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 92cb19f77f9..904176bcad0 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -41,12 +41,12 @@ # change) or a redesigned dashboard/runtime contract that no longer # needs an `sk-`-prefixed placeholder 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 placeholder # 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. # # Source-of-truth note for the `_translate_resumed_oneshot` parser # differential risk (NVIDIA/NemoClaw#5254): diff --git a/src/lib/onboard/managed-startup-image-runtime.test.ts b/src/lib/onboard/managed-startup-image-runtime.test.ts index b12b0ed0911..c3aacbf1e5b 100644 --- a/src/lib/onboard/managed-startup-image-runtime.test.ts +++ b/src/lib/onboard/managed-startup-image-runtime.test.ts @@ -843,7 +843,7 @@ describe("managed startup image runtime", () => { expect(() => readStableRegularFile(target, 1024)).toThrow(/changed while it was read/u); }); - it("promotes generated Hermes policy to one immutable runtime artifact", () => { + it("promotes generated Hermes policy to one root-owned, read-only runtime artifact", () => { const directory = temporaryDirectory(); const shareDirectory = path.join(directory, "share"); const source = path.join(directory, "managed-policy.json"); From 7d31337eabddfae86413a6f49830b99b98d90cbc Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 16:00:16 -0400 Subject: [PATCH 05/29] fix(security): classify config integrity digest Signed-off-by: Julie Yaunches --- src/lib/actions/inference-set.ts | 2 +- src/lib/sandbox/config.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index 8fb7e3bae76..3b9b7ceeced 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 { applyHermesManagedRoute, hermesApiMode } from "../hermes-managed-route"; +import { applyHermesManagedRoute } from "../hermes-managed-route"; import { isBedrockRuntimeEndpoint } from "../inference/bedrock-runtime"; import { getProviderSelectionConfig, diff --git a/src/lib/sandbox/config.ts b/src/lib/sandbox/config.ts index 3ad9f7a0279..63c3670e415 100644 --- a/src/lib/sandbox/config.ts +++ b/src/lib/sandbox/config.ts @@ -590,7 +590,8 @@ function writeSandboxConfig( if (result.issues.length > 0) { configFail(result.issues.map((issue) => ` ${issue}`)); } - const expectedNewDigest = createHash("sha256").update(content).digest("hex"); + // Integrity-only digest for guard output; this is not a password verifier. + const expectedNewDigest = createHash("sha256").update(content).digest("hex"); // codeql[js/insufficient-password-hash] if (result.configSha256 !== expectedNewDigest) { throw new Error( `OpenClaw config guard committed digest ${String(result.configSha256)} (expected ${expectedNewDigest})`, From c1b913e398988a0a72cde6f9164df4a5fb5599a0 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 16:11:00 -0400 Subject: [PATCH 06/29] fix(security): name Hermes proxy sentinel accurately Signed-off-by: Julie Yaunches --- .../actions/inference-set-hermes-run.test.ts | 12 ++++++------ .../actions/inference-set-patch-hermes.test.ts | 16 ++++++++-------- src/lib/hermes-managed-route.ts | 12 ++++++------ src/lib/sandbox/config.ts | 2 +- test/generate-hermes-config.test.ts | 18 +++++++++--------- 5 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/lib/actions/inference-set-hermes-run.test.ts b/src/lib/actions/inference-set-hermes-run.test.ts index 4c31521fe67..7491f7aa1ff 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-managed-route"; +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"; @@ -64,7 +64,7 @@ describe("runInferenceSet Hermes routing", () => { { name: "hermes-provider", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, discover_models: true, }, ], @@ -72,13 +72,13 @@ describe("runInferenceSet Hermes routing", () => { 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, }, providers: { "hermes-provider": { name: "hermes-provider", api: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, default_model: "openai/gpt-5.4-mini", discover_models: true, }, @@ -376,7 +376,7 @@ 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. @@ -534,7 +534,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 8453b4b61cc..33b9bcc6a59 100644 --- a/src/lib/actions/inference-set-patch-hermes.test.ts +++ b/src/lib/actions/inference-set-patch-hermes.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-managed-route"; +import { HERMES_PROXY_REWRITE_SENTINEL } from "../hermes-managed-route"; import type { ConfigObject } from "../security/credential-filter"; import { patchHermesInferenceConfig } from "./inference-set"; @@ -32,7 +32,7 @@ describe("patchHermesInferenceConfig", () => { 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, }); expect(config._nemoclaw_upstream).toEqual({ provider: "hermes-provider", @@ -43,7 +43,7 @@ describe("patchHermesInferenceConfig", () => { "hermes-provider": { name: "hermes-provider", api: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, default_model: "openai/gpt-5.4-mini", discover_models: true, }, @@ -52,7 +52,7 @@ describe("patchHermesInferenceConfig", () => { { name: "hermes-provider", base_url: "https://inference.local/v1", - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, discover_models: true, }, ]); @@ -79,7 +79,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); } }); @@ -104,7 +104,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", }); }); @@ -125,7 +125,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, }); }); @@ -149,7 +149,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/hermes-managed-route.ts b/src/lib/hermes-managed-route.ts index 00ccbcf149e..30db5326d5c 100644 --- a/src/lib/hermes-managed-route.ts +++ b/src/lib/hermes-managed-route.ts @@ -3,11 +3,11 @@ // 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_API_KEY_PLACEHOLDER = "sk-OPENSHELL-PROXY-REWRITE"; +export const HERMES_PROXY_REWRITE_SENTINEL = "sk-OPENSHELL-PROXY-REWRITE"; type HermesManagedProvider = { name: string; - api_key: typeof HERMES_PROXY_API_KEY_PLACEHOLDER; + api_key: typeof HERMES_PROXY_REWRITE_SENTINEL; discover_models: true; api?: string; base_url?: string; @@ -26,7 +26,7 @@ export type HermesManagedRouting = { default: string; provider: "custom"; base_url: string; - api_key: typeof HERMES_PROXY_API_KEY_PLACEHOLDER; + api_key: typeof HERMES_PROXY_REWRITE_SENTINEL; api_mode?: string; context_length?: number; }; @@ -89,7 +89,7 @@ export function applyHermesManagedRoute( default: route.model, provider: "custom", base_url: route.baseUrl, - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, }; if (apiMode) modelConfig.api_mode = apiMode; if (route.contextWindow !== null && route.contextWindow !== undefined) { @@ -100,7 +100,7 @@ export function applyHermesManagedRoute( const providerConfig: Record = { name: providerName, api: route.baseUrl, - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, default_model: route.model, discover_models: true, }; @@ -109,7 +109,7 @@ export function applyHermesManagedRoute( const customProvider: Record = { name: providerName, base_url: route.baseUrl, - api_key: HERMES_PROXY_API_KEY_PLACEHOLDER, + api_key: HERMES_PROXY_REWRITE_SENTINEL, discover_models: true, }; if (apiMode) customProvider.api_mode = apiMode; diff --git a/src/lib/sandbox/config.ts b/src/lib/sandbox/config.ts index 63c3670e415..b0634546699 100644 --- a/src/lib/sandbox/config.ts +++ b/src/lib/sandbox/config.ts @@ -591,7 +591,7 @@ function writeSandboxConfig( 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"); // codeql[js/insufficient-password-hash] + const expectedNewDigest = createHash("sha256").update(content).digest("hex"); if (result.configSha256 !== expectedNewDigest) { throw new Error( `OpenClaw config guard committed digest ${String(result.configSha256)} (expected ${expectedNewDigest})`, diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index adbe4ac3685..128100a1dbe 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -14,7 +14,7 @@ import { import { generateHermesConfig } from "../agents/hermes/config/generate.ts"; import { buildHermesManagedPolicy } 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-managed-route"; +import { HERMES_PROXY_REWRITE_SENTINEL } from "../src/lib/hermes-managed-route"; import { applyCompatibleEndpointContextWindow, resetCompatibleEndpointContextWindowAutoState, @@ -447,7 +447,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: { @@ -530,7 +530,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, }, @@ -539,7 +539,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, }, ]); @@ -681,7 +681,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", }); }); @@ -699,7 +699,7 @@ 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", @@ -732,13 +732,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", () => { 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 () => { @@ -1125,7 +1125,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(); From e0a6f10bec9110f066f0d93c379e9b0985a8b91b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 16:14:02 -0400 Subject: [PATCH 07/29] docs(hermes): align proxy sentinel terminology Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- agents/hermes/hermes-wrapper.py | 8 ++++---- src/lib/actions/inference-set-patch-hermes.test.ts | 2 +- test/generate-hermes-config.test.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 3b93d986e4d..788248112d9 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -356,7 +356,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,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=3bef63b35e7704906241166b87d1256d806d45d10d32415f1e418a5375aa4f22 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=790cd327a37180e99f3936be228a3d8a9c5b1816d8f972c8a445509a7293dc34 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 904176bcad0..b88dcee0700 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -30,7 +30,7 @@ # looks like a real credential. # - Value being masked: for configs generated by # `agents/hermes/config/managed-policy.ts:buildHermesManagedPolicy`, the literal -# placeholder `sk-OPENSHELL-PROXY-REWRITE` is hard-coded for the `model`, +# 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 @@ -39,10 +39,10 @@ # - 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 the managed policy ...` derives a -# fixture from `buildHermesManagedPolicy()` and asserts no raw placeholder +# 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 `buildHermesManagedPolicy` @@ -644,7 +644,7 @@ def _translate_resumed_oneshot(argv: list[str]) -> list[str] | None: # Source-of-truth note for the `_merge_provider_into_model` rewrite # (NVIDIA/NemoClaw#7361): # - Invalid state: separate --provider and -m/--model flags bypass the -# OpenShell proxy rewrite path; the raw .env placeholder is sent as the +# OpenShell proxy rewrite path; the raw .env sentinel is sent as the # bearer token, causing a 401. # - Fix: merge into the combined provider/model form at the wrapper boundary # so the invocation routes through the proxy credential resolution path. diff --git a/src/lib/actions/inference-set-patch-hermes.test.ts b/src/lib/actions/inference-set-patch-hermes.test.ts index 33b9bcc6a59..9c670004083 100644 --- a/src/lib/actions/inference-set-patch-hermes.test.ts +++ b/src/lib/actions/inference-set-patch-hermes.test.ts @@ -66,7 +66,7 @@ describe("patchHermesInferenceConfig", () => { expect(config.terminal).toEqual({ backend: "local" }); }); - it("replaces stale Hermes API keys with the OpenShell proxy placeholder", () => { + 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: { diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 128100a1dbe..5e27696d023 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -735,7 +735,7 @@ describe("agents/hermes/generate-config.ts", () => { 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_REWRITE_SENTINEL); From 0f5a217de47317bb4a3e04341370c4adad908650 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 16:25:32 -0400 Subject: [PATCH 08/29] fix(hermes): keep image probe discovery standalone Signed-off-by: Julie Yaunches --- agents/hermes/image-build-probes.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/agents/hermes/image-build-probes.py b/agents/hermes/image-build-probes.py index 32e0bea22ca..a9af680dc4b 100644 --- a/agents/hermes/image-build-probes.py +++ b/agents/hermes/image-build-probes.py @@ -11,12 +11,6 @@ sys.path.insert(0, "/usr/local/lib/nemoclaw") -from managed_policy import ( # noqa: E402 - load_managed_policy, - policy_value, - profile_default_values, -) - def verify_profile_policy() -> None: from types import SimpleNamespace @@ -26,6 +20,7 @@ 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, policy_value, profile_default_values from tools.browser_tool import ( _allow_unsafe_browser_evaluate, _restrict_browser_evaluate, @@ -227,6 +222,7 @@ def verify_wrapper_session_boundaries() -> 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")) policy = load_managed_policy() From 3534e25cc08eabb1572adacee6e649cbfef33d21 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 16:40:16 -0400 Subject: [PATCH 09/29] fix(hermes): refresh image probe integrity pin Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 788248112d9..9716c01eeef 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -196,7 +196,7 @@ RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ /scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-tar.mts \ && chmod -R a+rX /src/lib/messaging -ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=b1abbb4324b2147f4beb8af1c7bb9e66c80ec3a3559029800237e8a0bb9d0f91 +ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=fb84915fac84276d5d486a4b2319ffe0d964bd6e39b587181a66f676f3e2db9c # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256" /opt/nemoclaw-hermes-config/image-build-probes.py \ From 39edc96900b88287bbbe361267246ea224322fb0 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 31 Jul 2026 17:07:31 -0400 Subject: [PATCH 10/29] docs(hermes): define profile patch removal Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- agents/hermes/patch-profile-policy-defaults.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 9716c01eeef..8c21f3653f4 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -537,7 +537,7 @@ RUN install -o root -g root -m 0444 \ # 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=c4e9fb99d3fcf432cec829a126c15188fc799091c57a14834f3bd9a3edb63e6f +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 \ diff --git a/agents/hermes/patch-profile-policy-defaults.py b/agents/hermes/patch-profile-policy-defaults.py index d450d8aa459..a7866e3f17b 100755 --- a/agents/hermes/patch-profile-policy-defaults.py +++ b/agents/hermes/patch-profile-policy-defaults.py @@ -29,6 +29,12 @@ 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 From fe18a1494f587759c0fe880802cb35f3fd4cc752 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 21:25:32 -0400 Subject: [PATCH 11/29] docs(hermes): map managed policy lifecycle Signed-off-by: Julie Yaunches --- .../references/hermes-contract-map.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md b/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md index 85e7fb171ef..e81cbe68222 100644 --- a/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md +++ b/.agents/skills/nemoclaw-contributor-update-hermes/references/hermes-contract-map.md @@ -34,7 +34,12 @@ The complete Hermes range is the union of the generic evidence and every adjacen Audit these surfaces: -- `agents/hermes/config/managed-policy.ts`; +- `agents/hermes/config/managed-policy.ts` and the isolated Python reader in + `agents/hermes/managed_policy.py`; +- policy artifact writing and root-owned installation in `agents/hermes/config/write-config.ts` + and `src/lib/onboard/managed-startup/image-runtime.ts`; +- profile and dashboard consumers in `agents/hermes/patch-profile-policy-defaults.py`, + `agents/hermes/seed-dashboard-config.py`, and `agents/hermes/start.sh`; - `test/generate-hermes-config.test.ts`; - config generation and `hermes doctor --fix` order in `agents/hermes/Dockerfile`; - upstream `DEFAULT_CONFIG`, migrations, validation, and config-loading precedence. From c16cf2db03a4fdeca54d5eaf38df7f3f68cbf14b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 21:44:43 -0400 Subject: [PATCH 12/29] test(hermes): cover policy cleanup drift Signed-off-by: Julie Yaunches --- .../managed-startup-image-runtime.test.ts | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/src/lib/onboard/managed-startup-image-runtime.test.ts b/src/lib/onboard/managed-startup-image-runtime.test.ts index 13dbfbc5821..d88e0199384 100644 --- a/src/lib/onboard/managed-startup-image-runtime.test.ts +++ b/src/lib/onboard/managed-startup-image-runtime.test.ts @@ -376,6 +376,32 @@ describe("managed startup image runtime", () => { owned(realLstatSync(file, options))) as typeof fs.lstatSync); } + 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 }, + ) => { + if (file.toString() === source && options?.bigint === true) beforeSourceCleanup?.(); + 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); + } + function mockRootReplayFilesystem(runtimeWrites: string[]): void { const directories = new Set([ "/", @@ -929,23 +955,7 @@ describe("managed startup image runtime", () => { const policy = '{"schema_version":1}\n'; fs.mkdirSync(shareDirectory); fs.writeFileSync(source, policy, { mode: 0o600 }); - 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 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); + mockRootOwnedPolicyInstallPaths(source, shareDirectory, target); installHermesManagedPolicy(source, target); @@ -954,6 +964,25 @@ describe("managed startup image runtime", () => { expect(fs.statSync(target).mode & 0o777).toBe(0o444); }); + it("preserves generated Hermes policy when it changes before source cleanup", () => { + const directory = temporaryDirectory(); + 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("normalizes mutable sandbox-owned Hermes config descriptors to mode 0640", () => { const directory = temporaryDirectory(); const target = path.join(directory, "config.yaml"); From 936de32c2e60c62e441ae3fff0dee27be4c9d1a5 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 22:12:09 -0400 Subject: [PATCH 13/29] test(hermes): keep drift setup linear Signed-off-by: Julie Yaunches --- src/lib/onboard/managed-startup-image-runtime.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/managed-startup-image-runtime.test.ts b/src/lib/onboard/managed-startup-image-runtime.test.ts index d88e0199384..dceab61e5eb 100644 --- a/src/lib/onboard/managed-startup-image-runtime.test.ts +++ b/src/lib/onboard/managed-startup-image-runtime.test.ts @@ -394,7 +394,9 @@ describe("managed startup image runtime", () => { file: fs.PathLike, options?: { bigint?: boolean }, ) => { - if (file.toString() === source && options?.bigint === true) beforeSourceCleanup?.(); + 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; From 7672ce852a4316b15a2889daef453666515025d1 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Sun, 2 Aug 2026 22:43:35 -0400 Subject: [PATCH 14/29] fix(hermes): probe reset policy at gateway boundary Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- agents/hermes/image-build-probes.py | 30 ++++++++++++----- test/hermes-profile-policy-defaults.test.ts | 37 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index aa43e2b3edd..a6b7b57f495 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -197,7 +197,7 @@ RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ /scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-tar.mts \ && chmod -R a+rX /src/lib/messaging -ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=1666dd7e89df1dbff0361bb4b69ba7153dc259e41e59813f0399a946b553a2f3 +ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=4e7ed193bec2d58a77d513af24b2be690a33aa17d855694ee88400d3e9fef257 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256" /opt/nemoclaw-hermes-config/image-build-probes.py \ diff --git a/agents/hermes/image-build-probes.py b/agents/hermes/image-build-probes.py index 0ebc2ddc84b..8a57cfdb94d 100644 --- a/agents/hermes/image-build-probes.py +++ b/agents/hermes/image-build-probes.py @@ -12,6 +12,23 @@ 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 @@ -20,7 +37,7 @@ 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, policy_value, profile_default_values + from managed_policy import load_managed_policy, profile_default_values from tools.browser_tool import ( _allow_unsafe_browser_evaluate, _restrict_browser_evaluate, @@ -30,18 +47,15 @@ def verify_profile_policy() -> None: policy = load_managed_policy() expected = profile_default_values(policy) config = load_config_readonly() - for path, value in expected.items(): - assert policy_value(config, path) == value, (path, policy_value(config, path), value) + _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"] - assert SessionResetPolicy().mode == expected["session_reset.mode"] - assert SessionResetPolicy.from_dict({}).mode == expected["session_reset.mode"] + _verify_session_reset_policy(SessionResetPolicy(), expected) + _verify_session_reset_policy(SessionResetPolicy.from_dict({}), expected) gateway = load_gateway_config() - assert gateway.default_reset_policy.mode == expected["session_reset.mode"] - assert gateway.default_reset_policy.at_hour == expected["session_reset.at_hour"] - assert gateway.default_reset_policy.idle_minutes == expected["session_reset.idle_minutes"] + _verify_session_reset_policy(gateway.default_reset_policy, expected) original_load_config = hermes_config.load_config try: diff --git a/test/hermes-profile-policy-defaults.test.ts b/test/hermes-profile-policy-defaults.test.ts index 14ea7a9b7f0..46adab03c77 100644 --- a/test/hermes-profile-policy-defaults.test.ts +++ b/test/hermes-profile-policy-defaults.test.ts @@ -13,6 +13,7 @@ import { buildHermesManagedPolicy } from "../agents/hermes/config/managed-policy 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 POLICY_SETTINGS: HermesBuildSettings = { model: "test-model", @@ -269,6 +270,42 @@ describe("Hermes profile policy defaults", () => { 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"); From 5a68f823969d64ed0ffefaef2636fb2d6f61d095 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 00:06:32 -0400 Subject: [PATCH 15/29] chore(ci): retry trusted E2E gate Signed-off-by: Julie Yaunches From fac55ec51e1cdfb0b708454a37588c874251c336 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 00:51:59 -0400 Subject: [PATCH 16/29] chore(ci): retry cold onboard timing Signed-off-by: Julie Yaunches From e07f7cb2874b431638536cb3ff79e490735d1f4a Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 09:49:11 -0400 Subject: [PATCH 17/29] fix(hermes): preserve managed inference contracts Keep dashboard authentication out of mirrored configuration. Carry the selected model context window through Hermes inference switches. Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- agents/hermes/config/managed-policy.ts | 1 - agents/hermes/hermes-wrapper.py | 58 +++++++++- agents/hermes/seed-dashboard-config.py | 33 +++--- agents/hermes/start.sh | 10 +- docs/inference/configure-model-limits.mdx | 8 +- docs/inference/switch-models.mdx | 8 +- .../transfer-state-manually.mdx | 3 +- docs/security/credential-storage.mdx | 3 + .../actions/inference-set-hermes-run.test.ts | 44 ++++++++ .../inference-set-patch-hermes.test.ts | 21 ++++ src/lib/actions/inference-set.ts | 28 ++++- ...hermes-dashboard-credential-launch.test.ts | 102 ++++++++++++++++++ test/hermes-managed-policy.test.ts | 1 + test/seed-hermes-dashboard-config.test.ts | 43 +++++--- 15 files changed, 318 insertions(+), 47 deletions(-) create mode 100644 test/hermes-dashboard-credential-launch.test.ts diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index a6b7b57f495..b9fce5c2daa 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -374,7 +374,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,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=790cd327a37180e99f3936be228a3d8a9c5b1816d8f972c8a445509a7293dc34 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=51b032d7b0aa2c616500bbcd47f2c1b41749da8330b81a14ace289cac2161e73 ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 diff --git a/agents/hermes/config/managed-policy.ts b/agents/hermes/config/managed-policy.ts index 98363559a89..d9f0b71b09b 100644 --- a/agents/hermes/config/managed-policy.ts +++ b/agents/hermes/config/managed-policy.ts @@ -49,7 +49,6 @@ const DASHBOARD_ROUTING_KEYS = [ const DASHBOARD_ENV_KEYS = [ "API_SERVER_HOST", "API_SERVER_PORT", - "API_SERVER_KEY", "TAVILY_API_KEY", "NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER", "FIRECRAWL_GATEWAY_URL", diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index b88dcee0700..3b06322d550 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -88,10 +88,14 @@ # 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 os +import re +import stat import subprocess import sys import tempfile @@ -103,6 +107,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}$") # Trusted absolute paths for the python3 interpreter, ordered most-preferred # first. The resolver returns the first executable match (first-wins); the # same priority is mirrored by `agents/hermes/start.sh:resolve_trusted_python3` @@ -140,6 +146,52 @@ def _resolve_trusted_python3() -> str | None: return None +def _load_dashboard_api_server_key() -> bool: + """Load the dashboard bearer token into process env without a config mirror.""" + 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: + pass + + os.environ["API_SERVER_KEY"] = values[0] + return True + + _MASKER_STDERR_ALLOWED_PREFIX = "[SECURITY]" _MASKER_STDERR_MAX_BYTES = 10 * 1024 * 1024 @@ -808,6 +860,8 @@ def _merge_provider_into_model(argv: list[str]) -> list[str]: 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/seed-dashboard-config.py b/agents/hermes/seed-dashboard-config.py index e8ff59309f4..cffe7be6ebf 100755 --- a/agents/hermes/seed-dashboard-config.py +++ b/agents/hermes/seed-dashboard-config.py @@ -48,7 +48,6 @@ import grp import os import pwd -import re import stat import sys from copy import deepcopy @@ -63,9 +62,6 @@ policy_value, ) -API_SERVER_KEY_RE = re.compile(r"^[0-9a-f]{64}$") - - class UnsafeDashboardSeedPathError(Exception): pass @@ -86,13 +82,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: @@ -194,7 +183,7 @@ def _atomic_write_no_follow(dst: str, label: str, writer: Callable[[TextIO], Non pass -def _normalized_routing(gateway: dict, routing_keys: list[str]) -> dict: +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} @@ -218,6 +207,15 @@ def _normalized_routing(gateway: dict, routing_keys: list[str]) -> dict: or not custom_providers ): 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" + ) model["provider"] = provider_key return routing @@ -326,13 +324,6 @@ def parse_env_assignment(line: str) -> tuple[str, str] | None: key, value = parsed 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 != expected_values.get(key): print( "[SECURITY] Refusing to seed dashboard env because TAVILY_API_KEY " @@ -406,10 +397,10 @@ def main(argv: list[str]) -> int: return 1 try: - routing = _normalized_routing(gateway, policy["dashboard"]["routing_keys"]) + 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 diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 63d89282042..f0e4b6801f1 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1515,10 +1515,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. @@ -1548,6 +1548,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 @@ -1566,6 +1567,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/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 0ade7d9ed42..a28d8989d97 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/dashboard-home/.env` or `.hermes/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. diff --git a/docs/security/credential-storage.mdx b/docs/security/credential-storage.mdx index deb3d8a93d1..0de76c82fd3 100644 --- a/docs/security/credential-storage.mdx +++ b/docs/security/credential-storage.mdx @@ -24,6 +24,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/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-set-hermes-run.test.ts b/src/lib/actions/inference-set-hermes-run.test.ts index 7491f7aa1ff..7edbb03247b 100644 --- a/src/lib/actions/inference-set-hermes-run.test.ts +++ b/src/lib/actions/inference-set-hermes-run.test.ts @@ -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( @@ -73,6 +74,7 @@ describe("runInferenceSet Hermes routing", () => { provider: "custom", base_url: "https://inference.local/v1", api_key: HERMES_PROXY_REWRITE_SENTINEL, + context_length: 128_000, }, providers: { "hermes-provider": { @@ -121,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 () => { diff --git a/src/lib/actions/inference-set-patch-hermes.test.ts b/src/lib/actions/inference-set-patch-hermes.test.ts index 9c670004083..cbc3eb94af9 100644 --- a/src/lib/actions/inference-set-patch-hermes.test.ts +++ b/src/lib/actions/inference-set-patch-hermes.test.ts @@ -13,6 +13,7 @@ describe("patchHermesInferenceConfig", () => { default: "moonshotai/kimi-k2.6", provider: "custom", base_url: "https://old.example/v1", + context_length: 32_768, temperature: 0.2, }, models: { @@ -66,6 +67,26 @@ describe("patchHermesInferenceConfig", () => { expect(config.terminal).toEqual({ backend: "local" }); }); + 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 = { diff --git a/src/lib/actions/inference-set.ts b/src/lib/actions/inference-set.ts index a7f9165211e..fe0becafdea 100644 --- a/src/lib/actions/inference-set.ts +++ b/src/lib/actions/inference-set.ts @@ -556,6 +556,7 @@ 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); @@ -564,11 +565,29 @@ export function patchHermesInferenceConfig( 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, @@ -1177,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/test/hermes-dashboard-credential-launch.test.ts b/test/hermes-dashboard-credential-launch.test.ts new file mode 100644 index 00000000000..696c1f034b5 --- /dev/null +++ b/test/hermes-dashboard-credential-launch.test.ts @@ -0,0 +1,102 @@ +// 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 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.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.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-managed-policy.test.ts b/test/hermes-managed-policy.test.ts index 3889f6e3ec9..666a4d3c2e2 100644 --- a/test/hermes-managed-policy.test.ts +++ b/test/hermes-managed-policy.test.ts @@ -96,6 +96,7 @@ describe("Hermes managed policy", () => { 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); }); diff --git a/test/seed-hermes-dashboard-config.test.ts b/test/seed-hermes-dashboard-config.test.ts index 86a627ad2fa..9c0e9b8dea4 100644 --- a/test/seed-hermes-dashboard-config.test.ts +++ b/test/seed-hermes-dashboard-config.test.ts @@ -227,10 +227,33 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { const res = runSeed(src, dst); expect(res.status).toBe(1); - expect(res.stderr).toContain("no model routing"); + expect(res.stderr).toContain("invalid model routing"); expect(fs.existsSync(dst)).toBe(false); }); + it("rejects raw credentials in every mirrored routing shape (#8008)", () => { + for (const location of ["model", "provider", "custom provider"] as const) { + const gateway = structuredClone(GATEWAY_CONFIG); + if (location === "model") { + gateway.model.api_key = "sk-raw-model-credential"; + } else if (location === "provider") { + gateway.providers["nvidia-router"].api_key = "sk-raw-provider-credential"; + } else { + gateway.custom_providers[0].api_key = "sk-raw-custom-provider-credential"; + } + 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", () => { const src = writeYaml("gw.yaml", GATEWAY_CONFIG); const dst = path.join(tmpDir, "dash.yaml"); @@ -260,7 +283,6 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { [ "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", @@ -271,7 +293,7 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { 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"); @@ -289,12 +311,10 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { 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'", @@ -310,10 +330,9 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { 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"); } }); @@ -508,7 +527,7 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { 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", () => { @@ -525,7 +544,7 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { 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); }); From 0aeaf3f97c8996fe4e22c58dcf87a5be4ebf0151 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 09:59:33 -0400 Subject: [PATCH 18/29] test(hermes): keep credential denial setup linear Signed-off-by: Julie Yaunches --- test/seed-hermes-dashboard-config.test.ts | 30 +++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/test/seed-hermes-dashboard-config.test.ts b/test/seed-hermes-dashboard-config.test.ts index 9c0e9b8dea4..c0835147a98 100644 --- a/test/seed-hermes-dashboard-config.test.ts +++ b/test/seed-hermes-dashboard-config.test.ts @@ -232,15 +232,29 @@ describe.skipIf(!PY_YAML_AVAILABLE)("seed-dashboard-config.py", () => { }); it("rejects raw credentials in every mirrored routing shape (#8008)", () => { - for (const location of ["model", "provider", "custom provider"] as const) { + 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); - if (location === "model") { - gateway.model.api_key = "sk-raw-model-credential"; - } else if (location === "provider") { - gateway.providers["nvidia-router"].api_key = "sk-raw-provider-credential"; - } else { - gateway.custom_providers[0].api_key = "sk-raw-custom-provider-credential"; - } + injectRawCredential(gateway); const src = writeYaml(`gw-${location}.yaml`, gateway); const dst = writeYaml(`dash-${location}.yaml`, { dashboard_local: true }); const before = fs.readFileSync(dst, "utf8"); From b7c7510cd0b11520ad2574cd7bd71896300c64d4 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 10:07:34 -0400 Subject: [PATCH 19/29] fix(hermes): document credential cleanup safety Signed-off-by: Julie Yaunches --- agents/hermes/Dockerfile | 2 +- agents/hermes/hermes-wrapper.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index b9fce5c2daa..2cbcf8f5ca8 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -374,7 +374,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,validate-env-secret-boundary.py,finalize-tirith-marker.py}`. -ARG NEMOCLAW_HERMES_WRAPPER_SHA256=51b032d7b0aa2c616500bbcd47f2c1b41749da8330b81a14ace289cac2161e73 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=a942ec732374e1c2abaf75bd94075feb934bf84ff28568ff1764396b2c0b3e3d ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=822c7e63d068c5d09f3291350771c1a42c9686f51bfa9bc9a1f41fbe15d163b1 ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 # hadolint ignore=DL4006 diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 3b06322d550..889b898da93 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -186,6 +186,8 @@ def _load_dashboard_api_server_key() -> bool: 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] From b462e8ad4a4d0e542e5e46c54262b79305a30098 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 3 Aug 2026 13:16:36 -0400 Subject: [PATCH 20/29] test(e2e): retry unreachable Hermes MCP discovery --- test/e2e/live/mcp-bridge-tool-discovery.ts | 29 +++++++++++++++++++ test/e2e/live/mcp-bridge.test.ts | 7 ++++- .../support/mcp-bridge-tool-discovery.test.ts | 11 +++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/test/e2e/live/mcp-bridge-tool-discovery.ts b/test/e2e/live/mcp-bridge-tool-discovery.ts index 965735b1c5e..5f54d383c9a 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,29 @@ export async function assertAuthenticatedMcpDiscovery( .toMatchObject({ discovered: true }); } +export async function assertAuthenticatedMcpDiscoveryWithOneRestart( + fakeMcp: FakeMcpHttpsServer, + options: { + requestOffset: number; + expectedSecret: string; + label: string; + restart: () => Promise; + }, +): Promise { + try { + await assertAuthenticatedMcpDiscovery(fakeMcp, options); + } catch (error) { + if (!shouldRetryMcpDiscoveryAfterRestart(fakeMcp.requests.slice(options.requestOffset))) { + throw error; + } + await options.restart(); + await assertAuthenticatedMcpDiscovery(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 3cf1043cdea..c6634d54ef6 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -49,6 +49,7 @@ import { } from "./mcp-bridge-servers.ts"; import { assertAuthenticatedMcpDiscovery, + assertAuthenticatedMcpDiscoveryWithOneRestart, assertAuthenticatedMcpRediscovery, assertAuthenticatedMcpToolDiscovery, } from "./mcp-bridge-tool-discovery.ts"; @@ -1206,10 +1207,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..9b5e926e82d 100644 --- a/test/e2e/support/mcp-bridge-tool-discovery.test.ts +++ b/test/e2e/support/mcp-bridge-tool-discovery.test.ts @@ -11,6 +11,7 @@ import { } from "../live/mcp-bridge-servers.ts"; import { hasSuccessfulAuthenticatedMcpDiscovery, + shouldRetryMcpDiscoveryAfterRestart, shouldRetryMcpToolDiscoveryTransportFailure, } from "../live/mcp-bridge-tool-discovery.ts"; @@ -216,6 +217,16 @@ 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); + }); +}); + 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(); From 29831fb98c5de8f031282927cb5fd4aa41804d6d Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 11:56:32 -0400 Subject: [PATCH 21/29] fix(hermes): run neutral platform probe after patch --- agents/hermes/Dockerfile | 17 ++++++++++------- test/hermes-final-image-layout.test.ts | 7 ++++--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 3530c9ee72e..3f5cbeac60a 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -722,13 +722,6 @@ 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"'; \ - 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"}' \ - WHATSAPP_CLOUD_PHONE_NUMBER_ID=nemoclaw-hostile \ - 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 @@ -772,6 +765,16 @@ RUN /usr/bin/python3 -I \ "$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"}' \ + WHATSAPP_CLOUD_PHONE_NUMBER_ID=nemoclaw-hostile \ + 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 + RUN set -eu; \ profile_probe_root="$(mktemp -d)"; \ trap 'rm -rf "$profile_probe_root"' EXIT; \ diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index b5bd7d4ffac..02a81884ea2 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -394,6 +394,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); @@ -443,9 +444,9 @@ 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", From ede6bca2ee3bca89e29d64489d8d49a8f8ca059d Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 12:06:04 -0400 Subject: [PATCH 22/29] fix(hermes): refresh patched gateway hashes --- agents/hermes/Dockerfile | 4 ++-- test/hermes-final-image-layout.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 3f5cbeac60a..9f57391f8f4 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -749,8 +749,8 @@ RUN /usr/bin/python3 -I /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defa # 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 +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 \ diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 02a81884ea2..a96a6561844 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -449,11 +449,11 @@ describe("Hermes final image layout", () => { 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); From 233e08bd3e1f88994703b07581a4edf738ed04be Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 12:22:40 -0400 Subject: [PATCH 23/29] fix(hermes): validate managed rewrite sentinel --- agents/hermes/hermes-wrapper.py | 7 ++++++- agents/hermes/managed_policy.py | 5 +++++ test/hermes-dashboard-credential-launch.test.ts | 8 ++++++++ test/hermes-managed-policy.test.ts | 12 ++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index f48119d1c0b..feb39c4038f 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -151,7 +151,12 @@ def _resolve_trusted_python3() -> str | None: def _load_dashboard_api_server_key() -> bool: - """Load the dashboard bearer token into process env without a config mirror.""" + """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 diff --git a/agents/hermes/managed_policy.py b/agents/hermes/managed_policy.py index 099363f2b3c..1228b7b531d 100644 --- a/agents/hermes/managed_policy.py +++ b/agents/hermes/managed_policy.py @@ -12,6 +12,7 @@ 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): @@ -88,6 +89,10 @@ def load_managed_policy(path: Path = MANAGED_POLICY_PATH) -> dict: "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 Hermes proxy rewrite sentinel" + ) for managed_path in managed_paths: policy_value(config, managed_path) for key in dashboard["routing_keys"]: diff --git a/test/hermes-dashboard-credential-launch.test.ts b/test/hermes-dashboard-credential-launch.test.ts index e6717b91799..e808ed3973a 100644 --- a/test/hermes-dashboard-credential-launch.test.ts +++ b/test/hermes-dashboard-credential-launch.test.ts @@ -78,6 +78,14 @@ describe.skipIf(!PYTHON_AVAILABLE)("Hermes dashboard credential launch", () => { 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`], diff --git a/test/hermes-managed-policy.test.ts b/test/hermes-managed-policy.test.ts index 36ebee3a838..ff0e99dbac3 100644 --- a/test/hermes-managed-policy.test.ts +++ b/test/hermes-managed-policy.test.ts @@ -114,6 +114,18 @@ describe("Hermes managed policy", () => { 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, {})); + policy.config.model.api_key = rawCredential; + + const result = loadWithPython(policy); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must use the Hermes 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", From f9137f09ed21a21a970a8720d3a1b02fd43bf8f8 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 12:25:47 -0400 Subject: [PATCH 24/29] fix(hermes): name OpenShell rewrite boundary --- agents/hermes/managed_policy.py | 2 +- test/hermes-managed-policy.test.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/agents/hermes/managed_policy.py b/agents/hermes/managed_policy.py index 1228b7b531d..9b4cdd770b1 100644 --- a/agents/hermes/managed_policy.py +++ b/agents/hermes/managed_policy.py @@ -91,7 +91,7 @@ def load_managed_policy(path: Path = MANAGED_POLICY_PATH) -> dict: config = document["config"] if policy_value(config, "model.api_key") != HERMES_PROXY_REWRITE_SENTINEL: raise ManagedPolicyError( - "managed policy model.api_key must use the Hermes proxy rewrite sentinel" + "managed policy model.api_key must use the OpenShell proxy rewrite sentinel" ) for managed_path in managed_paths: policy_value(config, managed_path) diff --git a/test/hermes-managed-policy.test.ts b/test/hermes-managed-policy.test.ts index ff0e99dbac3..4acade7fdd9 100644 --- a/test/hermes-managed-policy.test.ts +++ b/test/hermes-managed-policy.test.ts @@ -117,12 +117,18 @@ describe("Hermes managed policy", () => { it("rejects a raw model credential without echoing it (#8008)", () => { const rawCredential = "sk-raw-policy-credential"; const policy = structuredClone(buildHermesManagedPolicy(SETTINGS, {})); - policy.config.model.api_key = rawCredential; + const malformedPolicy = { + ...policy, + config: { + ...policy.config, + model: { ...policy.config.model, api_key: rawCredential }, + }, + }; - const result = loadWithPython(policy); + const result = loadWithPython(malformedPolicy); expect(result.status).toBe(1); - expect(result.stderr).toContain("must use the Hermes proxy rewrite sentinel"); + expect(result.stderr).toContain("must use the OpenShell proxy rewrite sentinel"); expect(result.stderr).not.toContain(rawCredential); }); From 6a047843ebddaa26bab24a72fbebf54e79339719 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 12:30:48 -0400 Subject: [PATCH 25/29] fix(hermes): refresh wrapper integrity hash --- agents/hermes/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 9f57391f8f4..a71678b4587 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -541,7 +541,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=f0993294a00ccb8857bcc750ad9c6580f01781fbe76bd42bbc214d72288f356a +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 From 24f2b7bdeebb5944ce4c5a693985160bb5852702 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 12:56:52 -0400 Subject: [PATCH 26/29] test(e2e): cover bounded MCP discovery restart --- test/e2e/live/mcp-bridge-tool-discovery.ts | 13 ++- .../support/mcp-bridge-tool-discovery.test.ts | 79 ++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/test/e2e/live/mcp-bridge-tool-discovery.ts b/test/e2e/live/mcp-bridge-tool-discovery.ts index 5f54d383c9a..a64fc92447d 100644 --- a/test/e2e/live/mcp-bridge-tool-discovery.ts +++ b/test/e2e/live/mcp-bridge-tool-discovery.ts @@ -138,6 +138,14 @@ 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: { @@ -146,15 +154,16 @@ export async function assertAuthenticatedMcpDiscoveryWithOneRestart( label: string; restart: () => Promise; }, + deps: AuthenticatedMcpDiscoveryRestartDeps = AUTHENTICATED_MCP_DISCOVERY_RESTART_DEPS, ): Promise { try { - await assertAuthenticatedMcpDiscovery(fakeMcp, options); + await deps.assertDiscovery(fakeMcp, options); } catch (error) { if (!shouldRetryMcpDiscoveryAfterRestart(fakeMcp.requests.slice(options.requestOffset))) { throw error; } await options.restart(); - await assertAuthenticatedMcpDiscovery(fakeMcp, { + await deps.assertDiscovery(fakeMcp, { ...options, label: `${options.label} after one bridge restart`, }); diff --git a/test/e2e/support/mcp-bridge-tool-discovery.test.ts b/test/e2e/support/mcp-bridge-tool-discovery.test.ts index 9b5e926e82d..cdd2f1dba58 100644 --- a/test/e2e/support/mcp-bridge-tool-discovery.test.ts +++ b/test/e2e/support/mcp-bridge-tool-discovery.test.ts @@ -1,15 +1,17 @@ // 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 { + type FakeMcpHttpsServer, type FakeMcpRequest, HERMES_DEFERRED_TOOL_SEARCH_MISS, type StartedHttpServer, startCompatibleMock, } from "../live/mcp-bridge-servers.ts"; import { + assertAuthenticatedMcpDiscoveryWithOneRestart, hasSuccessfulAuthenticatedMcpDiscovery, shouldRetryMcpDiscoveryAfterRestart, shouldRetryMcpToolDiscoveryTransportFailure, @@ -225,6 +227,81 @@ describe("authenticated MCP discovery restart retry", () => { 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", () => { From 475480294896f881c3d7cc9741f78d9b365c218f Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 5 Aug 2026 22:34:11 -0400 Subject: [PATCH 27/29] test(e2e): retry concurrent MCP removal Signed-off-by: Julie Yaunches --- test/e2e/live/mcp-bridge-cleanup.ts | 7 +++++++ test/e2e/live/mcp-bridge.test.ts | 12 +++++++++-- .../support/mcp-bridge-tool-discovery.test.ts | 20 ++++++++++++++++++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/test/e2e/live/mcp-bridge-cleanup.ts b/test/e2e/live/mcp-bridge-cleanup.ts index 73f718d7529..f4e9e842dc6 100644 --- a/test/e2e/live/mcp-bridge-cleanup.ts +++ b/test/e2e/live/mcp-bridge-cleanup.ts @@ -33,3 +33,10 @@ export async function cleanupMcpBridge( `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); +} diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index fbaf44879fd..eb10469d4eb 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -22,8 +22,9 @@ import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { type McpBridgeShard, resolveMcpBridgeShard } from "./mcp-bridge-agent-selection.ts"; import { cleanupMcpBridge, - type McpAdapter, MCP_MUTATION_TIMEOUT_MS, + type McpAdapter, + shouldRetryMcpMutationAfterConcurrencyConflict, } from "./mcp-bridge-cleanup.ts"; import { assertHermesConfig, @@ -553,11 +554,18 @@ async function removeBridgeAndAssertEmpty( mcpUrl: string; }, ): Promise { - const remove = await host.nemoclaw([options.sandboxName, "mcp", "remove", SERVER_NAME], { + let 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], }); + if (remove.exitCode !== 0 && shouldRetryMcpMutationAfterConcurrencyConflict(resultText(remove))) { + remove = await host.nemoclaw([options.sandboxName, "mcp", "remove", SERVER_NAME], { + artifactName: `${options.artifactPrefix}-mcp-remove-fake-server-retry`, + env: buildAvailabilityProbeEnv(), + timeoutMs: MCP_MUTATION_TIMEOUT_MS[options.adapter], + }); + } 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`, diff --git a/test/e2e/support/mcp-bridge-tool-discovery.test.ts b/test/e2e/support/mcp-bridge-tool-discovery.test.ts index cdd2f1dba58..45818a6eebc 100644 --- a/test/e2e/support/mcp-bridge-tool-discovery.test.ts +++ b/test/e2e/support/mcp-bridge-tool-discovery.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { afterEach, describe, expect, it, vi } from "vitest"; - +import { shouldRetryMcpMutationAfterConcurrencyConflict } from "../live/mcp-bridge-cleanup.ts"; import { type FakeMcpHttpsServer, type FakeMcpRequest, @@ -382,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); + }); +}); From 2a9784db4f10d99c1798e7ad7693dd5730433a7b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Thu, 6 Aug 2026 00:12:18 -0400 Subject: [PATCH 28/29] test(e2e): keep MCP retry helper linear Signed-off-by: Julie Yaunches --- test/e2e/live/mcp-bridge-cleanup.ts | 26 ++++++++++++++++++++++++++ test/e2e/live/mcp-bridge.test.ts | 21 ++++++++------------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/test/e2e/live/mcp-bridge-cleanup.ts b/test/e2e/live/mcp-bridge-cleanup.ts index f4e9e842dc6..82f8b36471a 100644 --- a/test/e2e/live/mcp-bridge-cleanup.ts +++ b/test/e2e/live/mcp-bridge-cleanup.ts @@ -3,6 +3,7 @@ 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"; @@ -40,3 +41,28 @@ const MCP_MUTATION_CONCURRENCY_CONFLICT = 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.test.ts b/test/e2e/live/mcp-bridge.test.ts index eb10469d4eb..664352a26ef 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -24,7 +24,7 @@ import { cleanupMcpBridge, MCP_MUTATION_TIMEOUT_MS, type McpAdapter, - shouldRetryMcpMutationAfterConcurrencyConflict, + removeMcpBridgeWithOneConcurrencyRetry, } from "./mcp-bridge-cleanup.ts"; import { assertHermesConfig, @@ -554,18 +554,13 @@ async function removeBridgeAndAssertEmpty( mcpUrl: string; }, ): Promise { - let 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], - }); - if (remove.exitCode !== 0 && shouldRetryMcpMutationAfterConcurrencyConflict(resultText(remove))) { - remove = await host.nemoclaw([options.sandboxName, "mcp", "remove", SERVER_NAME], { - artifactName: `${options.artifactPrefix}-mcp-remove-fake-server-retry`, - 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`, From 6fc2763013e5c258b731fadb3b9efcc5b6526c9a Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Thu, 6 Aug 2026 01:27:47 -0400 Subject: [PATCH 29/29] fix(inference): resolve readiness comparator merge Signed-off-by: Julie Yaunches --- src/lib/inference/serving/resolver.test.ts | 10 +++++-- src/lib/inference/serving/resolver.ts | 31 ---------------------- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/src/lib/inference/serving/resolver.test.ts b/src/lib/inference/serving/resolver.test.ts index f7db87263a3..81a670bffb3 100644 --- a/src/lib/inference/serving/resolver.test.ts +++ b/src/lib/inference/serving/resolver.test.ts @@ -195,8 +195,9 @@ function readinessSources(): ManagedInferenceReadinessSource[] { function storageRemediableReadinessReport( extraFindings: SystemReadinessReport["findings"] = [], + preset: ManagedInferenceServingPreset = shippedPreset(), ): SystemReadinessReport { - const report = readinessReport(); + const report = readinessReport({}, preset); return { ...report, capabilities: [ @@ -829,7 +830,12 @@ describe("managed inference resolver", () => { const presetId = catalog.presets[0]!.metadata.id; const result = resolveManagedInferenceServing( { - readinessReports: [{ nodeId: "spark-head", report: storageRemediableReadinessReport() }], + readinessReports: [ + { + nodeId: "spark-head", + report: storageRemediableReadinessReport([], catalog.presets[0]!), + }, + ], topologyQualifications: [], intent: { preset: presetId }, now: NOW, diff --git a/src/lib/inference/serving/resolver.ts b/src/lib/inference/serving/resolver.ts index d7fb743ea2f..f8f06d83d58 100644 --- a/src/lib/inference/serving/resolver.ts +++ b/src/lib/inference/serving/resolver.ts @@ -185,37 +185,6 @@ function matchesOperator(actual: unknown, operator: SelectionOperator, expected: } } -function versionAtLeast(actual: unknown, minimum: string): boolean { - if (typeof actual !== "string") return false; - const versionPattern = /^\d+(?:\.\d+)*$/u; - if (!versionPattern.test(actual) || !versionPattern.test(minimum)) return false; - const actualParts = actual.split(".").map(Number); - const minimumParts = minimum.split(".").map(Number); - const width = Math.max(actualParts.length, minimumParts.length); - for (let index = 0; index < width; index += 1) { - const actualPart = actualParts[index] ?? 0; - const minimumPart = minimumParts[index] ?? 0; - if (actualPart !== minimumPart) return actualPart > minimumPart; - } - return true; -} - -function readinessComparisonMatches( - actual: unknown, - comparison: ServingReadinessComparison, -): boolean { - switch (comparison.operator) { - case "equals": - return scalarEquals(actual, comparison.value); - case "one-of": - return comparison.values.some((candidate) => scalarEquals(actual, candidate)); - case "at-least": - return typeof actual === "number" && actual >= comparison.value; - case "version-at-least": - return versionAtLeast(actual, comparison.value); - } -} - function readinessScopeMatches( scope: string, reports: readonly ManagedInferenceReadinessSource[],