diff --git a/.github/actions/resolve-hermes-base-image/action.yaml b/.github/actions/resolve-hermes-base-image/action.yaml index f1637ba6eb9..e51890e263a 100644 --- a/.github/actions/resolve-hermes-base-image/action.yaml +++ b/.github/actions/resolve-hermes-base-image/action.yaml @@ -29,7 +29,7 @@ runs: --read-only \ --user sandbox \ --entrypoint /opt/hermes/.venv/bin/python "$ref" -I -c \ - 'import importlib.metadata as metadata; import sys; import acp; import mcp; from acp_adapter.server import HermesACPAgent; from tools import mcp_tool; metadata.version("agent-client-protocol") == "0.9.0" or sys.exit(1); getattr(mcp_tool, "_MCP_AVAILABLE", False) or sys.exit(1); getattr(mcp_tool, "_MCP_HTTP_AVAILABLE", False) or sys.exit(1)' \ + 'import importlib.metadata as metadata; import sys; import acp; import mcp; from acp_adapter.server import HermesACPAgent; from tools import mcp_tool; metadata.version("agent-client-protocol") == "0.9.0" or sys.exit(1); mcp_tool._ensure_mcp_sdk() or sys.exit(1); getattr(mcp_tool, "_MCP_AVAILABLE", False) or sys.exit(1); getattr(mcp_tool, "_MCP_HTTP_AVAILABLE", False) or sys.exit(1)' \ >/dev/null 2>&1 } diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index 17d6911b874..c681a1cd388 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -625,6 +625,13 @@ jobs: run: | set -euo pipefail image_json="$(docker image inspect "$IMAGE_REFERENCE")" + if [ "$AGENT" = "hermes" ]; then + layer_count="$(jq -er '.[0].RootFS.Layers | length' <<< "$image_json")" + if [ "$layer_count" -gt 125 ]; then + echo "ERROR: Hermes managed image has ${layer_count} filesystem layers; the publication runner compatibility ceiling is 125." >&2 + exit 1 + fi + fi image_id="$( jq -er ' if length == 1 and (.[0].Id | type) == "string" @@ -856,6 +863,13 @@ jobs: reference="${IMAGE}@${DIGEST}" raw="$RUNNER_TEMP/${AGENT}-published-manifest.raw" scripts/checks/pull-public-exact-digest.sh "$reference" linux/amd64 + if [ "$AGENT" = "hermes" ]; then + published_layer_count="$(docker image inspect --format '{{len .RootFS.Layers}}' "$reference")" + if [ "$published_layer_count" -gt 125 ]; then + echo "ERROR: Hermes managed image has ${published_layer_count} filesystem layers; the publication runner compatibility ceiling is 125." >&2 + exit 1 + fi + fi docker buildx imagetools inspect "$reference" --raw > "$raw" [[ "sha256:$(sha256sum "$raw" | awk '{print $1}')" == "$DIGEST" ]] || { echo "ERROR: published PR manifest bytes do not match the build digest" >&2 diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index d2998e71969..b5e6664f534 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -6,7 +6,7 @@ # Layers PR-specific code (plugin, config, startup script) on top of the # pre-built Hermes base image. Mirrors the OpenClaw Dockerfile structure. -ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:d30f58b0374c7d281df07c126cc257baaf63eb051264a575b96099ec037d5e5d +ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:c588bf76ba1c280f8a366bdfd672193e852de4f509a280662c7070a9b6e2fa48 ARG NEMOCLAW_CORPORATE_CA_B64= ARG NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=0 # BuildKit supplies this automatic platform argument. The Portable staged @@ -77,8 +77,8 @@ COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-br COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts COPY agents/hermes/runtime-boundaries.patch /scripts/hermes-runtime-boundaries.patch -COPY agents/hermes/security-dependencies.patch /scripts/hermes-security-dependencies.patch COPY agents/hermes/dashboard-external-host.patch /scripts/hermes-dashboard-external-host.patch +COPY agents/hermes/secure-dir-skip-chmod.patch /scripts/hermes-secure-dir-skip-chmod.patch FROM scratch AS hermes-agent-payload @@ -88,12 +88,18 @@ COPY agents/hermes/plugin/__init__.py agents/hermes/plugin/plugin.yaml /opt/nemo COPY src/lib/messaging/channels/googlechat/runtime/hermes-adapter.py /opt/nemoclaw-hermes-plugin/googlechat_adapter.py COPY agents/hermes/generate-config.ts /opt/nemoclaw-hermes-config/generate-config.ts COPY agents/hermes/config/ /opt/nemoclaw-hermes-config/config/ -COPY agents/hermes/image-build-probes.py /opt/nemoclaw-hermes-config/image-build-probes.py -COPY agents/hermes/patch-gateway-runtime-metadata.py /opt/nemoclaw-hermes-config/patch-gateway-runtime-metadata.py +COPY agents/hermes/image-build-probes.py agents/hermes/finalize-image-layout.sh /opt/nemoclaw-hermes-config/ +COPY agents/hermes/a2a-neutral.patch agents/hermes/patch-gateway-runtime-metadata.py /opt/nemoclaw-hermes-config/ COPY agents/hermes/patch-gateway-process-identity.py /opt/nemoclaw-hermes-config/patch-gateway-process-identity.py COPY agents/hermes/patch-cron-execution-runtime.py /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py COPY agents/hermes/patch-cron-restore-drain.py /opt/nemoclaw-hermes-config/patch-cron-restore-drain.py COPY agents/hermes/patch-neutral-platform-env-activation.py /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py +COPY agents/hermes/patch-session-list-preview.py /opt/nemoclaw-hermes-config/patch-session-list-preview.py +COPY agents/hermes/patch-profile-policy-defaults.py /opt/nemoclaw-hermes-config/patch-profile-policy-defaults.py +COPY agents/hermes/hindsight-lazy-integrity.patch /opt/nemoclaw-hermes-config/hindsight-lazy-integrity.patch +COPY agents/hermes/hindsight-client-probe-requirements.txt /opt/nemoclaw-hermes-config/hindsight-client-requirements.txt +ADD --checksum=sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54 https://files.pythonhosted.org/packages/1a/99/84ba7273339d0f3dfa57901b846489d2e5c2cd731470167757f1935fffbd/aiohttp_retry-2.9.1-py3-none-any.whl /opt/nemoclaw-hermes-config/hindsight-probe-aiohttp-retry.whl +COPY agents/hermes/managed_policy.py /opt/nemoclaw-hermes-config/managed_policy.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 @@ -117,10 +123,8 @@ COPY --from=managed-startup-runtime-builder /out/managed-startup-image-runtime.c COPY scripts/gateway-control.sh /usr/local/bin/nemoclaw-gateway-control COPY scripts/managed-gateway-control.py /usr/local/lib/nemoclaw/managed-gateway-control.py COPY agents/hermes/validate-env-secret-boundary.py /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py -COPY agents/hermes/patch-session-list-preview.py /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py COPY agents/hermes/patch-hermes-sqlite-temp-store.py /usr/local/lib/nemoclaw/patch-hermes-sqlite-temp-store.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 @@ -149,6 +153,7 @@ ADD --checksum=sha256:0072ffe68863a4c62818a4e631a186f092a4f09dfda74d1d4713415bac ADD --checksum=sha256:e2b0257d9b8782830df61eb6aa993a1ddc0349daddd845739da45d2a29a0c44b https://files.pythonhosted.org/packages/7d/b8/c4e5df0f6797363ff8a431bcee1a51a7d8b6fe2e5d4cac21a6bd58e9f50f/microsoft_teams_cards-2.0.15-py3-none-any.whl /microsoft_teams_cards-2.0.15-py3-none-any.whl ADD --checksum=sha256:c61057695b9f1a97de9b6f54f0c66206903f56c22427b0bca31e0fc34da49311 https://files.pythonhosted.org/packages/8d/91/01e6aeddd78639c74489c24785f2cec0f842ba16f3b594db202386bd721d/microsoft_teams_common-2.0.15-py3-none-any.whl /microsoft_teams_common-2.0.15-py3-none-any.whl ADD --checksum=sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl /msal-1.37.0-py3-none-any.whl +ADD --checksum=sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440 https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl /pydantic_settings-2.14.2-py3-none-any.whl # Google Chat needs the google SDKs, which Hermes does not package: its # pyproject declares no google_chat extra, and `_load_google_modules()` @@ -205,6 +210,7 @@ FROM ${BASE_IMAGE} # Keep the stock-image lazy dependency target when a published base image lags # Dockerfile.base. The final stage also creates and verifies the directory. ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages +ENV HERMES_SKIP_CHMOD=1 # Base64-encoded host corporate-proxy CA bundle (#6210). Empty by default. When # onboard detects an operator-supplied corporate CA on the host it bakes it @@ -212,7 +218,10 @@ ENV HERMES_LAZY_INSTALL_TARGET=/sandbox/.hermes/lazy-packages # appends to the OpenShell trust bundle at runtime. The CA is a public # certificate, not a secret, so baking it into an image layer is acceptable. ARG NEMOCLAW_CORPORATE_CA_B64 -ARG NEMOCLAW_HERMES_RUNTIME_BOUNDARIES_PATCH_SHA256=9e41bed797965990bf7edf214c782c18c04d086a15042415ae7085a507873b1c +ARG NEMOCLAW_HERMES_RUNTIME_BOUNDARIES_PATCH_SHA256=820563cc3450de582663c8137670001bb9974ac8a5584901880982f6509747b8 +ARG NEMOCLAW_HERMES_SECURE_DIR_SOURCE_SHA256=ffae3271120cf53eb8a7f574f76758eac3ea172f9ddc22c1056277e37d8d392c +ARG NEMOCLAW_HERMES_SECURE_DIR_PATCH_SHA256=1c1a898226bc67b123ec72847d4dcda7abbab1e95958c9e019790d9425e15d69 +ARG NEMOCLAW_HERMES_SECURE_DIR_OUTPUT_SHA256=130cf4e76d6f4f16b85517cf8d3d81dfe294aff63d6c561b979971c401f78761 # Decode the host corporate-proxy CA (#6210) to a root-owned, read-only file # when onboard baked one in. No-op when NEMOCLAW_CORPORATE_CA_B64 is empty. The @@ -247,29 +256,23 @@ RUN if [ -n "${NEMOCLAW_CORPORATE_CA_B64}" ]; then \ # one final-image layer while preserving metadata on existing parent paths. COPY --from=hermes-npm-patch-payload / / -# A published base can lag the source patches in Dockerfile.base. Apply only -# the required final-image hunks, then verify the source contracts in one layer -# so the already-deep Hermes image remains importable by Docker. +# The immutable selected base owns the reviewed Hindsight and agent-browser +# patches and the root-owned agent-browser runtime. Verify that inherited boundary +# without adding a second network-capable install owner in the final image. +# Apply the dashboard Host guard when the published 0.20.6 base predates that +# source fix, then verify the exact accepted-host contract. # hadolint ignore=DL4006 -RUN if ! grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py; then \ - git -C /opt/hermes apply --check \ - --include=hermes_cli/memory_setup.py \ - /scripts/hermes-security-dependencies.patch; \ - git -C /opt/hermes apply \ - --include=hermes_cli/memory_setup.py \ - /scripts/hermes-security-dependencies.patch; \ - fi \ - && if ! grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml; then \ - git -C /opt/hermes apply --check \ - --include=plugins/memory/hindsight/plugin.yaml \ - /scripts/hermes-security-dependencies.patch; \ - git -C /opt/hermes apply \ - --include=plugins/memory/hindsight/plugin.yaml \ - /scripts/hermes-security-dependencies.patch; \ - fi \ - && grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py \ +RUN grep -Fq 'from tools.lazy_deps import install_specs' /opt/hermes/hermes_cli/memory_setup.py \ + && grep -Fq 'outcome = install_specs(missing, timeout=120)' /opt/hermes/hermes_cli/memory_setup.py \ && grep -Fqx ' - "hindsight-client==0.6.1"' /opt/hermes/plugins/memory/hindsight/plugin.yaml \ - && rm /scripts/hermes-security-dependencies.patch \ + && grep -Fqx 'AGENT_BROWSER_NPX_SPEC = "agent-browser@0.26.0"' /opt/hermes/tools/browser_tool.py \ + && test "$(readlink /usr/local/bin/agent-browser)" = \ + '/opt/nemoclaw-agent-browser-runtime/node_modules/.bin/agent-browser' \ + && agent_browser_version="$( \ + /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + /usr/local/bin/agent-browser --version \ + )" \ + && test "$agent_browser_version" = 'agent-browser 0.26.0' \ && if ! grep -Fq 'if external_host and host_only == external_host:' /opt/hermes/hermes_cli/web_server.py; then \ git -C /opt/hermes apply --check \ --include=hermes_cli/web_server.py \ @@ -281,6 +284,23 @@ RUN if ! grep -Fq 'ensure("memory.hindsight", prompt=False)' /opt/hermes/hermes_ && grep -Fqx '_NEMOCLAW_DASHBOARD_EXTERNAL_HOST_ENV = "_NEMOCLAW_HERMES_DASHBOARD_EXTERNAL_HOST"' /opt/hermes/hermes_cli/web_server.py \ && grep -Fq 'if external_host and host_only == external_host:' /opt/hermes/hermes_cli/web_server.py \ && rm /scripts/hermes-dashboard-external-host.patch \ + && printf '%s %s\n' \ + "$NEMOCLAW_HERMES_SECURE_DIR_PATCH_SHA256" /scripts/hermes-secure-dir-skip-chmod.patch \ + | sha256sum -c - \ + || { echo "ERROR: Hermes secure-directory patch identity mismatch" >&2; exit 1; } \ + && secure_dir_source_sha="$(sha256sum /opt/hermes/hermes_cli/config.py | awk '{print $1}')" \ + && if [ "$secure_dir_source_sha" = "$NEMOCLAW_HERMES_SECURE_DIR_SOURCE_SHA256" ]; then \ + git -C /opt/hermes apply --check /scripts/hermes-secure-dir-skip-chmod.patch; \ + git -C /opt/hermes apply /scripts/hermes-secure-dir-skip-chmod.patch; \ + elif [ "$secure_dir_source_sha" != "$NEMOCLAW_HERMES_SECURE_DIR_OUTPUT_SHA256" ]; then \ + echo "ERROR: Hermes secure-directory source identity mismatch" >&2; \ + exit 1; \ + fi \ + && printf '%s %s\n' \ + "$NEMOCLAW_HERMES_SECURE_DIR_OUTPUT_SHA256" /opt/hermes/hermes_cli/config.py \ + | sha256sum -c - \ + || { echo "ERROR: Hermes secure-directory patched output identity mismatch" >&2; exit 1; } \ + && rm /scripts/hermes-secure-dir-skip-chmod.patch \ && printf '%s %s\n' "$NEMOCLAW_HERMES_RUNTIME_BOUNDARIES_PATCH_SHA256" /scripts/hermes-runtime-boundaries.patch | sha256sum -c - \ && if ! grep -Fq 'def nemoclaw_managed_gateway_plugins_only()' /opt/hermes/hermes_constants.py; then \ git -C /opt/hermes apply --check \ @@ -351,13 +371,6 @@ RUN set -eu; \ test -x /usr/local/bin/hermes; \ /usr/local/bin/hermes --version -# Managed MCP and ACP require their packaged Hermes client surfaces. A -# published base can carry the expected Hermes version while omitting either -# optional dependency group. This is a build-time package/import guard; live -# protocol behavior remains a separate E2E concern. -RUN /opt/hermes/.venv/bin/python -I -c \ - 'import importlib.metadata as metadata; import sys; import acp; import mcp; from acp_adapter.server import HermesACPAgent; from tools import mcp_tool; metadata.version("agent-client-protocol") == "0.9.0" or sys.exit("Hermes ACP SDK version is unavailable"); getattr(mcp_tool, "_MCP_AVAILABLE", False) or sys.exit("Hermes MCP client runtime is unavailable"); getattr(mcp_tool, "_MCP_HTTP_AVAILABLE", False) or sys.exit("Hermes MCP Streamable HTTP runtime is unavailable")' - RUN chmod -R a+rX /opt/hermes/.venv \ && test -r /opt/hermes/.venv/pyvenv.cfg \ && su -s /bin/sh sandbox -c '/opt/hermes/.venv/bin/python3 -c "import urllib.request"' @@ -436,7 +449,21 @@ ENV HERMES_TUI_DIR="/opt/hermes/ui-tui" COPY --from=hermes-agent-payload / / +# Managed MCP and ACP require their packaged Hermes client surfaces. A +# published base can carry the expected Hermes version while omitting either +# optional dependency group. This is a build-time package/import guard; live +# protocol behavior remains a separate E2E concern. +ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=7c74c3f190845fef222e3aac1bd7074d58968447e78221114344545c149595c2 +# hadolint ignore=DL3059,DL4006 +RUN printf '%s %s\n' \ + "$NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256" /opt/nemoclaw-hermes-config/image-build-probes.py \ + | sha256sum -c - \ + || { echo "ERROR: image-build-probes.py hash mismatch (update NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256)" >&2; exit 1; }; \ + /opt/hermes/.venv/bin/python -I \ + /opt/nemoclaw-hermes-config/image-build-probes.py managed-runtime-capability + # Ensure the NemoClaw plugin for Hermes is readable. +# hadolint ignore=DL3059 RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/ # Keep the config generator and its source dependencies root-owned and @@ -450,12 +477,22 @@ RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ /scripts/patch-bundled-npm-tar.mts \ && chmod -R a+rX /src/lib/messaging -ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=7737ba80de8098c4f76b7407f3e41f10e9c95b8ade10b702b5d6b341e7164fc7 +# Hermes 0.20.6 pins the Hindsight version but does not authenticate a +# same-version wheel selected by its lazy installer. Bind that production +# boundary to NVIDIA/NemoClaw's reviewed wheel hashes. +ARG NEMOCLAW_HERMES_HINDSIGHT_LAZY_INTEGRITY_PATCH_SHA256=5c619477c10e0b76cffd7adc214cbccf3beb77e7a1121f394c443502d9e0b736 +ARG NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256=7c74c3f190845fef222e3aac1bd7074d58968447e78221114344545c149595c2 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ - "$NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256" /opt/nemoclaw-hermes-config/image-build-probes.py \ + "$NEMOCLAW_HERMES_HINDSIGHT_LAZY_INTEGRITY_PATCH_SHA256" /opt/nemoclaw-hermes-config/hindsight-lazy-integrity.patch \ | sha256sum -c - \ - || { echo "ERROR: image-build-probes.py hash mismatch (update NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256)" >&2; exit 1; } + && git -C /opt/hermes apply --check \ + --include=tools/lazy_deps.py \ + /opt/nemoclaw-hermes-config/hindsight-lazy-integrity.patch \ + && git -C /opt/hermes apply \ + --include=tools/lazy_deps.py \ + /opt/nemoclaw-hermes-config/hindsight-lazy-integrity.patch \ + && rm /opt/nemoclaw-hermes-config/hindsight-lazy-integrity.patch COPY --from=hermes-runtime-payload / / @@ -527,7 +564,7 @@ RUN chmod -R a+rX /opt/nemoclaw-blueprint/ # profile hook, bashrc hook, or root-owned helper mode. Remove it once the # minimum supported Hermes sandbox base tag guarantees those artifacts and # test/runtime/sandbox/sandbox-rlimit-hooks.test.ts covers that base. -RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-startup-hold /usr/local/bin/nemoclaw-managed-bootstrap /usr/local/lib/nemoclaw/sandbox-init.sh /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py /usr/local/lib/nemoclaw/patch-hermes-sqlite-temp-store.py /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py /usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py /usr/local/lib/nemoclaw/hermes-runtime-config-guard.py /usr/local/lib/nemoclaw/finalize-tirith-marker.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py \ +RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-startup-hold /usr/local/bin/nemoclaw-managed-bootstrap /usr/local/lib/nemoclaw/sandbox-init.sh /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py /usr/local/lib/nemoclaw/patch-hermes-sqlite-temp-store.py /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py /usr/local/lib/nemoclaw/seed-hermes-dashboard-config.py /usr/local/lib/nemoclaw/hermes-runtime-config-guard.py /usr/local/lib/nemoclaw/finalize-tirith-marker.py /usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py \ && chown root:root /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/gateway-supervisor.sh /usr/local/lib/nemoclaw/managed-gateway-control.py /usr/local/lib/nemoclaw/build-hermes-mcp-digest.py /usr/local/lib/nemoclaw/hermes-cron-restore-control.py /usr/local/lib/nemoclaw/openshell-child-visible-credentials.v0.0.106.json \ && chmod 700 /usr/local/bin/nemoclaw-gateway-control /usr/local/lib/nemoclaw/hermes-cron-restore-control.py \ && chmod 500 /usr/local/lib/nemoclaw/managed-gateway-control.py \ @@ -565,22 +602,25 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-managed-star 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; } -# Hermes v0.19.0's operator drain is scoped to one container epoch. NemoClaw's +# Hermes v0.20.6's operator drain is scoped to one container epoch. NemoClaw's # rebuild drain must instead survive gateway and container restarts until the # restored scripts and cron jobs are revalidated. Patch the pinned drain -# predicate to compose an independent root-owned marker, and hydrate that -# predicate during GatewayRunner construction so startup cannot dispatch before -# the asynchronous watcher gets its first tick. -ARG NEMOCLAW_HERMES_CRON_RESTORE_DRAIN_PATCHER_SHA256=10f757140106e88133e160a62fdbf0fa971cf8edcdbba13948eb88b12a8aeb2e -ARG NEMOCLAW_HERMES_DRAIN_CONTROL_SOURCE_SHA256=5436daa7807ce3e765dac084974af025c8ffd0515faca15ac455ade2595e4af7 -ARG NEMOCLAW_HERMES_GATEWAY_RUN_SOURCE_SHA256=c6e0f443772e4a8a7eac0d9ccf9a4f659de5fc5493c572a69a46e4c61a8aa966 -ARG NEMOCLAW_HERMES_DRAIN_CONTROL_PATCHED_SHA256=544da7eebee82a1de1291f1921325ec5a1f2a70ca08ad64f27f8efe868a0089b -ARG NEMOCLAW_HERMES_GATEWAY_RUN_DRAIN_PATCHED_SHA256=3180fe6e62ee986243c7bc85c343108075b94f3cbda42d91273a6a104ad47c1b +# predicate to compose an independent root-owned marker, hydrate that predicate +# during GatewayRunner construction, and re-arm only unclaimed one-shots held +# overdue when the authenticated release record opens the gate. +ARG NEMOCLAW_HERMES_CRON_RESTORE_DRAIN_PATCHER_SHA256=d28c55ebad5a10c3086d2cf8360f136002f9a9fca1a9ec129f75365b76ef4a74 +ARG NEMOCLAW_HERMES_DRAIN_CONTROL_SOURCE_SHA256=4195245d27b7b067b416b69f4728fcfcdd97c912bdb0954f14402b35601f76cc +ARG NEMOCLAW_HERMES_GATEWAY_RUN_SOURCE_SHA256=8b8def1d9cb8088d4de7dd3d920d31fb0d959f4d6ffffadf6dd8f8320da5e00b +ARG NEMOCLAW_HERMES_CRON_JOBS_SOURCE_SHA256=dd5c7c601e23e5058e93d824b17ff426ed2cc5113c20d431ea4937274be59923 +ARG NEMOCLAW_HERMES_DRAIN_CONTROL_PATCHED_SHA256=6d1c20afd6d0030a4d7c0fcc5a82dea1a9d9b912886aff1343597cfe74be1c8a +ARG NEMOCLAW_HERMES_GATEWAY_RUN_DRAIN_PATCHED_SHA256=30a36097a525449bc9d3b721ba3c3047b1c4f2d8a19da8027bb280867c2d6823 +ARG NEMOCLAW_HERMES_CRON_JOBS_DRAIN_PATCHED_SHA256=1ff52425b966497831e911eb89e7707238d7a03e1839f8c941ded3eedb3216dd # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_CRON_RESTORE_DRAIN_PATCHER_SHA256" /opt/nemoclaw-hermes-config/patch-cron-restore-drain.py \ "$NEMOCLAW_HERMES_DRAIN_CONTROL_SOURCE_SHA256" /opt/hermes/gateway/drain_control.py \ "$NEMOCLAW_HERMES_GATEWAY_RUN_SOURCE_SHA256" /opt/hermes/gateway/run.py \ + "$NEMOCLAW_HERMES_CRON_JOBS_SOURCE_SHA256" /opt/hermes/cron/jobs.py \ | sha256sum -c - \ || { echo "ERROR: Hermes cron restore drain source identity mismatch" >&2; exit 1; } # hadolint ignore=DL4006 @@ -588,19 +628,21 @@ RUN /usr/bin/python3 -I \ /opt/nemoclaw-hermes-config/patch-cron-restore-drain.py \ --drain-control /opt/hermes/gateway/drain_control.py \ --gateway-run /opt/hermes/gateway/run.py \ + --cron-jobs /opt/hermes/cron/jobs.py \ && printf '%s %s\n' \ "$NEMOCLAW_HERMES_DRAIN_CONTROL_PATCHED_SHA256" /opt/hermes/gateway/drain_control.py \ "$NEMOCLAW_HERMES_GATEWAY_RUN_DRAIN_PATCHED_SHA256" /opt/hermes/gateway/run.py \ + "$NEMOCLAW_HERMES_CRON_JOBS_DRAIN_PATCHED_SHA256" /opt/hermes/cron/jobs.py \ | sha256sum -c - -# Hermes v0.19.0 writes gateway lifecycle metadata directly below HERMES_HOME. +# Hermes v0.20.6 writes gateway lifecycle metadata directly below HERMES_HOME. # Managed stop/start recovery needs this metadata in the writable runtime -# boundary. Relocate only Hermes's -# central PID/lock/status path helpers to the existing writable runtime +# boundary. Relocate only Hermes's central PID/lock/status path helpers to the +# existing writable runtime # directory while preserving Hermes's process-scoped home selection. Direct # upstream --replace/stop and named-profile consumers remain the documented # inherited residual; this probe does not claim a complete upstream relocation. -ARG NEMOCLAW_HERMES_GATEWAY_RUNTIME_METADATA_PATCHER_SHA256=8379a9a74569e7bd578ec5d3d8e80e6bde4a928357196cb983e625db26775e06 +ARG NEMOCLAW_HERMES_GATEWAY_RUNTIME_METADATA_PATCHER_SHA256=d5fc78c39f15ac2da436a48aa5df82af9b2aacb7f19fd7e449ffd154b871de60 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_GATEWAY_RUNTIME_METADATA_PATCHER_SHA256" /opt/nemoclaw-hermes-config/patch-gateway-runtime-metadata.py \ @@ -629,14 +671,14 @@ RUN /usr/bin/python3 -I \ && /opt/hermes/.venv/bin/python -I \ /opt/nemoclaw-hermes-config/image-build-probes.py gateway-process-identity -# Hermes v0.19.0 creates its writable cron execution-history database below +# Hermes v0.20.6 creates its writable cron execution-history database below # the directory that contains cron job definitions. Relocate only this audit # database to the existing cross-UID runtime boundary, and keep # Hermes' quick snapshot inventory aligned. Full source hashes and shape-constrained # patching force a fresh review when either pinned upstream module changes. -ARG NEMOCLAW_HERMES_CRON_RUNTIME_PATCHER_SHA256=0ae5bad0e00f9a2425f2c05a5067a0b8db9ed13cf5d4e1eefc085cfac7309f0a -ARG NEMOCLAW_HERMES_CRON_EXECUTIONS_SOURCE_SHA256=b37215a27a453191420622f78dc8962fa44feac2521a6f51d71b18831e7cacb7 -ARG NEMOCLAW_HERMES_BACKUP_SOURCE_SHA256=1bcef6f736f1d52055837789f24becdba4a670f0a1abb5ac9973b1a1a7306f35 +ARG NEMOCLAW_HERMES_CRON_RUNTIME_PATCHER_SHA256=b8aae8925cacad654d7b09900bd1cc02d776feaec4662c06dde8f4b2f356e22e +ARG NEMOCLAW_HERMES_CRON_EXECUTIONS_SOURCE_SHA256=b4a685a901abdffe2d1232099b3c27391775775a7011d52c90276cb15d3fd75d +ARG NEMOCLAW_HERMES_BACKUP_SOURCE_SHA256=b0838c1f2e120d8f97076c6321297077edc1f150dc6d4b84ba3d13327fcbb156 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_CRON_RUNTIME_PATCHER_SHA256" /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py \ @@ -652,19 +694,31 @@ RUN set -eu; \ probe_home="$(mktemp -d)"; \ trap 'rm -rf "$probe_home"' EXIT; \ HERMES_HOME="$probe_home" /opt/hermes/.venv/bin/python -I \ - /opt/nemoclaw-hermes-config/image-build-probes.py cron-runtime-source - -# Hermes v0.19.0 computes `sessions list` preview from the first user message, -# while #5254's user-facing expectation is that the existing row reflects the -# latest resumed/continued one-shot turn. Patch only the pinned query shape and -# prove the SessionDB list contract at build time so a Hermes update cannot -# silently drift. -RUN /usr/bin/python3 -I /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py \ + /opt/nemoclaw-hermes-config/image-build-probes.py cron-runtime-source \ + && rm /opt/nemoclaw-hermes-config/patch-gateway-runtime-metadata.py \ + /opt/nemoclaw-hermes-config/patch-gateway-process-identity.py \ + /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py \ + /opt/nemoclaw-hermes-config/patch-cron-restore-drain.py + +# Hermes v0.20.6 computes `sessions list` preview from the first user message. +# Its workspace-aware titled table then hides the preview behind an automatic +# seed title. Patch both pinned source shapes and prove that an automatically +# titled workspace row renders the latest resumed or continued one-shot turn +# while a user-authored title remains authoritative. +ARG NEMOCLAW_HERMES_SESSION_LIST_PREVIEW_PATCHER_SHA256=718315e7c36727ca49ac88a1be44f95e4d495ca737ba70d5bb3d5c509fd9a0e2 +# hadolint ignore=DL4006 +RUN printf '%s %s\n' \ + "$NEMOCLAW_HERMES_SESSION_LIST_PREVIEW_PATCHER_SHA256" /opt/nemoclaw-hermes-config/patch-session-list-preview.py \ + | sha256sum -c - \ + || { echo "ERROR: patch-session-list-preview.py hash mismatch (update NEMOCLAW_HERMES_SESSION_LIST_PREVIEW_PATCHER_SHA256)" >&2; exit 1; }; \ + /usr/bin/python3 -I /opt/nemoclaw-hermes-config/patch-session-list-preview.py \ && grep -q 'ORDER BY m.timestamp DESC, m.id DESC LIMIT 1' /opt/hermes/hermes_state.py \ + && grep -Fq 'if s.get("title_source") in ("derived", "llm")' /opt/hermes/hermes_cli/sessions_cmd.py \ && HERMES_HOME="$(mktemp -d)" /opt/hermes/.venv/bin/python -I \ - /opt/nemoclaw-hermes-config/image-build-probes.py session-preview + /opt/nemoclaw-hermes-config/image-build-probes.py session-preview \ + && rm /opt/nemoclaw-hermes-config/patch-session-list-preview.py -# Hermes v0.19.0 SessionDB does not set PRAGMA temp_store=MEMORY, so SQLite +# Hermes v0.20.6 SessionDB does not set PRAGMA temp_store=MEMORY, so SQLite # falls back to file-based temp storage for FK constraint processing (for # example, the ON DELETE CASCADE on session_model_usage -> sessions). The # OpenShell sandbox execution context used by @@ -674,7 +728,7 @@ RUN /usr/bin/python3 -I /usr/local/lib/nemoclaw/patch-hermes-session-list-previe # `sqlite3.OperationalError: unable to open database file` (#8301). Inserting # PRAGMA temp_store=MEMORY before PRAGMA foreign_keys=ON ensures FK processing # uses in-memory storage. Docker execution permits the file-based storage path. -ARG NEMOCLAW_HERMES_SQLITE_TEMP_STORE_PATCHER_SHA256=1eff38472fa25f915bfc1d8d5168cbb3f1600a0e322f88b18974d01a8a4f713f +ARG NEMOCLAW_HERMES_SQLITE_TEMP_STORE_PATCHER_SHA256=495afa4b7b960a3df8d46c2380561307773449ca5bfa96ca8f4a49c4147c4b6a # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_SQLITE_TEMP_STORE_PATCHER_SHA256" /usr/local/lib/nemoclaw/patch-hermes-sqlite-temp-store.py \ @@ -685,12 +739,12 @@ RUN /usr/bin/python3 -I /usr/local/lib/nemoclaw/patch-hermes-sqlite-temp-store.p && HERMES_HOME="$(mktemp -d)" /opt/hermes/.venv/bin/python -I \ /opt/nemoclaw-hermes-config/image-build-probes.py session-delete -# Hermes v0.19.0 forces the gateway-created Discord recovery ledger to 0600, +# Hermes v0.20.6 forces the gateway-created Discord recovery ledger to 0600, # which prevents sandbox-owned online backup and makes a sandbox-restored # database unwritable by the gateway. The final layout uses a gateway:sandbox # setgid parent, and this source patch retains group write on the ledger. # Remove it when upstream supports an equivalent cross-identity mode contract. -ARG NEMOCLAW_HERMES_DISCORD_RECOVERY_PATCHER_SHA256=e42f0712c2895c8d1c766c0748c110dc830394b2caf63c3f1a99a113ab374628 +ARG NEMOCLAW_HERMES_DISCORD_RECOVERY_PATCHER_SHA256=5e47a50da6290a2cc4604904aa70192c2b5f59c1e66af4f154ab452bc0e23899 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_DISCORD_RECOVERY_PATCHER_SHA256" /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py \ @@ -706,13 +760,13 @@ RUN /usr/bin/python3 -I /usr/local/lib/nemoclaw/patch-hermes-discord-recovery-pe RUN /usr/bin/python3 -I /opt/nemoclaw-hermes-config/image-build-probes.py \ googlechat-override-seams -# Hermes v0.19.0's bundled Langfuse plugin rejects OpenShell resolver +# Hermes v0.20.6's bundled Langfuse plugin rejects OpenShell resolver # placeholders before the SDK can construct its Basic-auth headers. Accept only # the OpenShell public and secret placeholders when each placeholder is bound # to its matching standard Langfuse key. Raw keys keep Hermes' upstream # pk-lf-/sk-lf- validation, and every authenticated client requires an absolute # HTTPS base URL. The patcher fails closed when the pinned source shape changes. -ARG NEMOCLAW_HERMES_LANGFUSE_PATCHER_SHA256=a505357f9991d85e42d6cb2fcd320fb6f3a989f090a7969c0460265254e7d9c6 +ARG NEMOCLAW_HERMES_LANGFUSE_PATCHER_SHA256=c1f490f1a5ee6bbcceec628a5180b309dec66d539f71b8ce2bf2de08ccb5eadd # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_LANGFUSE_PATCHER_SHA256" /usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts \ @@ -733,12 +787,12 @@ 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=ef6051859986987c74eaf771ce1cfdbd3e06f236f019830730e7f92536d82081 -ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=989edf54a8c09c6efb348600a8aa2f264c0b71408eb9d7bcd579b92cbeccf9b1 +ARG NEMOCLAW_HERMES_CLI_ADAPTER_SHA256=3c769f05292b840424a8feec9a9eb441e393a48bf0212bd824b0db8a33a66331 +ARG NEMOCLAW_HERMES_WRAPPER_SHA256=b008a27a53c8ddc1189d3ba03490e29e48c5a026f412deeb73b0bc3098923214 ARG NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256=db4046e79e513eab67b069a8eda20167b8b65529cf26842531d2ad673c670330 -ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=676caae2adcd5654e33bf4acf645e8499cf801f5979c1a77d42ce764ecdcea36 +ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=6937ddb650532e473b84a5e56fb26573491d1bc20230ba6c2bfd2e6f6d777edb ARG NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256=a1e6b1c53ab297569abb87c29d15c294d729e46005bfd022136b4c447a791819 -ARG NEMOCLAW_HERMES_CRON_RESTORE_CONTROLLER_SHA256=e8593cf1580bffa4663e91c079ba0ce31c3d26391f5b1718872701138ce250b0 +ARG NEMOCLAW_HERMES_CRON_RESTORE_CONTROLLER_SHA256=dbd32832383f174878c08529947f5d81911152e1516b6f960824db3c7f7ac1e9 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_VALIDATOR_SHA256" /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py \ @@ -769,20 +823,9 @@ RUN printf '%s %s\n' \ COPY --from=hermes-wrapper-payload / / RUN test -x /usr/bin/python3 \ - || { echo "ERROR: /usr/bin/python3 missing or not executable; hermes-wrapper shebang would ENOEXEC" >&2; exit 1; } -# hadolint ignore=DL4006 -RUN hermes_version_output="$(/usr/local/bin/hermes --version)" \ - && hermes_semver="$(printf '%s\n' "$hermes_version_output" | sed -n 's/.*v\([0-9][0-9]*[.][0-9][0-9]*[.][0-9][0-9]*\).*/\1/p; s/^\([0-9][0-9]*[.][0-9][0-9]*[.][0-9][0-9]*\)$/\1/p' | head -1)" \ - && if [ -z "$hermes_semver" ]; then \ - echo "ERROR: could not parse Hermes semver from: $hermes_version_output" >&2; \ - exit 1; \ - fi \ - && if [ "$hermes_semver" != "0.19.0" ] \ - && { grep -q '"resumed_oneshot"' /usr/local/share/nemoclaw/hermes-cli-adapter-v1.json \ - || grep -q 'EXPECTED_OCCURRENCES' /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py; }; then \ - echo "ERROR: installed Hermes ${hermes_semver} but Hermes v0.19.0 compatibility workarounds are still installed; re-review #5254 workaround removal before upgrading Hermes" >&2; \ - exit 1; \ - fi + || { echo "ERROR: /usr/bin/python3 missing or not executable; hermes-wrapper shebang would ENOEXEC" >&2; exit 1; }; \ + /opt/hermes/.venv/bin/python -I \ + /opt/nemoclaw-hermes-config/image-build-probes.py compatibility-retirement # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_WRAPPER_SHA256" /usr/local/lib/nemoclaw/hermes-wrapper.py \ @@ -902,13 +945,14 @@ RUN if [ "$NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION" = "1" ]; then \ UV_OFFLINE=true UV_FIND_LINKS=/opt/nemoclaw-hermes-teams-wheels \ node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts \ --agent hermes --phase managed-image-capability-union; \ - fi; \ - /opt/hermes/.venv/bin/python -I -c \ + fi \ + && /opt/hermes/.venv/bin/python -I -c \ "from importlib.metadata import version; expected = {'aiohttp': '3.14.3', 'cryptography': '50.0.0'}; actual = {name: version(name) for name in expected}; assert actual == expected, actual" \ && rm -rf /opt/nemoclaw-hermes-teams-wheels WORKDIR /sandbox -RUN test "$(id -u sandbox):$(id -g sandbox):$(pwd)" = "998:999:/sandbox" +RUN test "$(id -u sandbox):$(id -g sandbox):$(pwd)" = "998:999:/sandbox" \ + && rm -rf /sandbox/.cache USER sandbox # Set up blueprint for local resolution @@ -923,10 +967,10 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ # for the OpenShell provider pipeline. # SECURITY: Uses a separate script file instead of inline code to avoid # code injection via build-arg interpolation (same concern as OpenClaw C-2). -RUN HERMES_HOME=/sandbox/.hermes /usr/local/bin/hermes doctor --fix \ - && node --experimental-strip-types /opt/nemoclaw-hermes-config/generate-config.ts \ +RUN /opt/hermes/.venv/bin/python -I \ + /opt/nemoclaw-hermes-config/image-build-probes.py prepare-generated-config \ && 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"'; \ + /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) == 32; assert all(value == {"enabled": False} for value in neutral.values()); assert m.version("microsoft-teams-apps") == "2.0.13.4"; assert m.version("aiohttp") == "3.14.3"'; \ fi \ && rm -rf /sandbox/.cache @@ -941,36 +985,62 @@ 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=424336d2ee3a12b4fb979ed84401ef105bf9c70e36dc3aa27a70f2a46b46def9 +ARG NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256=71388064485328862ea1783d24dc576f0c99a6d22057d2bc0f81af15697c05f6 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ - "$NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256" /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py \ + "$NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256" /opt/nemoclaw-hermes-config/patch-profile-policy-defaults.py \ | sha256sum -c - \ - || { echo "ERROR: patch-hermes-profile-policy-defaults.py hash mismatch (update NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256)" >&2; exit 1; } -RUN /usr/bin/python3 -I /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py -# Hermes v0.19.0 has several built-in environment override paths that can + || { echo "ERROR: patch-profile-policy-defaults.py hash mismatch (update NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256)" >&2; exit 1; }; \ + /usr/bin/python3 -I /opt/nemoclaw-hermes-config/patch-profile-policy-defaults.py \ + && grep -Fqx ' env["npm_config_offline"] = "true"' /opt/hermes/tools/browser_tool.py \ + && rm /opt/nemoclaw-hermes-config/patch-profile-policy-defaults.py \ + /opt/nemoclaw-hermes-config/managed_policy.py +# Hermes v0.20.6 has several built-in environment override paths that can # re-enable an explicitly disabled platform when ambient credentials exist. # Apply this after the profile-policy patch above, then bind both its # output module and this narrow patcher before preserving explicit disables # across environment processing. -ARG NEMOCLAW_HERMES_NEUTRAL_PLATFORM_PATCHER_SHA256=29769d0ae10646dd0a66b32dbefd5f85540544172d57cac96bb4f0941e260948 -ARG NEMOCLAW_HERMES_POST_PROFILE_GATEWAY_CONFIG_SHA256=b50a8390311c828fa9e13084e9af0caadafe2380ae161ef36dd4bdf792b22ee6 -ARG NEMOCLAW_HERMES_NEUTRAL_PLATFORM_OUTPUT_SHA256=77ad342af30d59a5b863d9f5f817247d816fd582fb12d38e074243f88d85b9f4 +ARG NEMOCLAW_HERMES_NEUTRAL_PLATFORM_PATCHER_SHA256=43b90f768d5a627417d1eef72427abb1cf256e348cd7d1a91bf2afd32fe9894b +ARG NEMOCLAW_HERMES_POST_PROFILE_GATEWAY_CONFIG_SHA256=c0080c4da9b04c4749e92561c1c68a3f4debbc62a502cd47b139180442219621 +ARG NEMOCLAW_HERMES_NEUTRAL_PLATFORM_OUTPUT_SHA256=6fd9eac44ddb41d4e6684964b73693a1b830e504eff4f3113c8391461805eec8 # hadolint ignore=DL4006 RUN printf '%s %s\n' \ "$NEMOCLAW_HERMES_NEUTRAL_PLATFORM_PATCHER_SHA256" /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py \ "$NEMOCLAW_HERMES_POST_PROFILE_GATEWAY_CONFIG_SHA256" /opt/hermes/gateway/config.py \ | sha256sum -c - \ - || { echo "ERROR: Hermes neutral platform environment source identity mismatch" >&2; exit 1; } -# hadolint ignore=DL4006 -RUN /usr/bin/python3 -I \ + || { echo "ERROR: Hermes neutral platform environment source identity mismatch" >&2; exit 1; }; \ + /usr/bin/python3 -I \ /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py \ /opt/hermes/gateway/config.py \ && printf '%s %s\n' \ "$NEMOCLAW_HERMES_NEUTRAL_PLATFORM_OUTPUT_SHA256" /opt/hermes/gateway/config.py \ | sha256sum -c - +# A2A has no accepted NVIDIA/NemoClaw product scope. Keep the inbound adapter +# disabled and remove its outbound tools from every Hermes process. +ARG NEMOCLAW_HERMES_A2A_NEUTRAL_PATCH_SHA256=f265d2023472d0cb375d7b302fb5ca88585766ba6943c519a755a987cfe3f7ce +# hadolint ignore=DL4006 +RUN printf '%s %s\n' \ + "$NEMOCLAW_HERMES_A2A_NEUTRAL_PATCH_SHA256" /opt/nemoclaw-hermes-config/a2a-neutral.patch \ + | sha256sum -c - \ + && git -C /opt/hermes apply --check \ + --include=plugins/platforms/a2a/plugin.yaml \ + --include=plugins/platforms/a2a/__init__.py \ + /opt/nemoclaw-hermes-config/a2a-neutral.patch \ + && git -C /opt/hermes apply \ + --include=plugins/platforms/a2a/plugin.yaml \ + --include=plugins/platforms/a2a/__init__.py \ + /opt/nemoclaw-hermes-config/a2a-neutral.patch \ + && ! grep -Fq 'provides_tools:' /opt/hermes/plugins/platforms/a2a/plugin.yaml \ + && ! grep -Fq 'from .tools import register_tools' /opt/hermes/plugins/platforms/a2a/__init__.py \ + && HERMES_HOME=/sandbox/.hermes /opt/hermes/.venv/bin/python -I -c \ + 'from hermes_cli.plugins import discover_plugins; from tools.registry import registry; discover_plugins(); assert registry.get_tool_names_for_toolset("a2a") == []; assert not any(name.startswith("a2a_") for name in registry.get_all_tool_names())' \ + && rm /opt/nemoclaw-hermes-config/a2a-neutral.patch + RUN if [ "$NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION" = "1" ]; then \ + A2A_PORT=9900 \ + BUZZ_RELAY_URL=https://nemoclaw-hostile.invalid \ + BUZZ_PRIVATE_KEY=nemoclaw-hostile \ 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"}' \ @@ -978,7 +1048,8 @@ RUN if [ "$NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION" = "1" ]; then \ 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 + fi \ + && rm /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py RUN set -eu; \ profile_probe_root="$(mktemp -d)"; \ @@ -1061,78 +1132,18 @@ USER root # must never enter a Hermes final image. OpenShell starts the sandbox as the # sandbox user, so the .hermes-data migration cannot rely on root privileges # inside the pod. +ARG NEMOCLAW_HERMES_FINALIZE_IMAGE_LAYOUT_SHA256=775c82aaad35ee9e788b85ed624e837507c1e7ba86e775260b356a03a7d0ac86 # hadolint ignore=DL4006 RUN set -eu; \ - config_dir=/sandbox/.hermes; \ - data_dir=/sandbox/.hermes-data; \ - openclaw_dir=/sandbox/.openclaw; \ - if [ -e "$openclaw_dir" ] || [ -L "$openclaw_dir" ]; then \ - echo "ERROR: Hermes base image contains retired OpenClaw state: $openclaw_dir" >&2; \ - exit 1; \ - fi; \ - mkdir -p "$config_dir"; \ - if [ -L "$data_dir" ]; then \ - echo "ERROR: refusing legacy layout cleanup because $data_dir is a symlink" >&2; \ - exit 1; \ - fi; \ - if [ -d "$data_dir" ]; then \ - legacy_link="$(find "$data_dir" -type l -print -quit)"; \ - if [ -n "$legacy_link" ]; then \ - echo "ERROR: refusing legacy layout cleanup because $legacy_link is a symlink" >&2; \ - exit 1; \ - fi; \ - for entry in "$data_dir"/*; do \ - [ -e "$entry" ] || [ -L "$entry" ] || continue; \ - name="$(basename "$entry")"; \ - target="$config_dir/$name"; \ - if [ -L "$target" ]; then \ - rm -f "$target"; \ - fi; \ - if [ -d "$entry" ]; then \ - mkdir -p "$target"; \ - cp -a "$entry"/. "$target"/; \ - elif [ ! -e "$target" ]; then \ - cp -a "$entry" "$target"; \ - fi; \ - done; \ - data_real="$(readlink -f "$data_dir" 2>/dev/null || printf '%s' "$data_dir")"; \ - while :; do \ - replaced_marker="$(mktemp)"; \ - rm -f "$replaced_marker"; \ - find "$config_dir" -type l -print | while IFS= read -r link; do \ - raw_target="$(readlink "$link" 2>/dev/null || true)"; \ - resolved_target="$(readlink -f "$link" 2>/dev/null || true)"; \ - legacy_target=0; \ - case "$raw_target" in "$data_real"/* | "$data_dir"/*) legacy_target=1 ;; esac; \ - case "$resolved_target" in "$data_real"/* | "$data_dir"/*) legacy_target=1 ;; esac; \ - if [ "$legacy_target" -eq 1 ]; then \ - copy_target="$resolved_target"; \ - if [ -z "$copy_target" ] || { [ ! -e "$copy_target" ] && [ ! -L "$copy_target" ]; }; then \ - copy_target="$raw_target"; \ - fi; \ - if [ -d "$copy_target" ] && [ ! -L "$copy_target" ]; then \ - rm -f "$link"; \ - mkdir -p "$link"; \ - cp -a "$copy_target"/. "$link"/; \ - elif [ -e "$copy_target" ] || [ -L "$copy_target" ]; then \ - rm -f "$link"; \ - cp -a "$copy_target" "$link"; \ - else \ - echo "ERROR: legacy symlink target missing: $link -> ${raw_target:-$resolved_target}" >&2; \ - exit 1; \ - fi; \ - : > "$replaced_marker"; \ - fi; \ - done; \ - if [ ! -e "$replaced_marker" ]; then \ - rm -f "$replaced_marker"; \ - break; \ - fi; \ - rm -f "$replaced_marker"; \ - done; \ - rm -rf "$data_dir"; \ - fi; \ - mkdir -p "$config_dir/memories" \ + printf '%s %s\n' \ + "$NEMOCLAW_HERMES_FINALIZE_IMAGE_LAYOUT_SHA256" /opt/nemoclaw-hermes-config/finalize-image-layout.sh \ + | sha256sum -c - \ + || { echo "ERROR: finalize-image-layout.sh hash mismatch (update NEMOCLAW_HERMES_FINALIZE_IMAGE_LAYOUT_SHA256)" >&2; exit 1; }; \ + /bin/bash /opt/nemoclaw-hermes-config/finalize-image-layout.sh /sandbox \ + && rm /opt/nemoclaw-hermes-config/finalize-image-layout.sh \ + && config_dir=/sandbox/.hermes \ + && data_dir=/sandbox/.hermes-data \ + && mkdir -p "$config_dir/memories" \ "$config_dir/sessions" \ "$config_dir/skills" \ "$config_dir/plugins" \ @@ -1347,9 +1358,9 @@ RUN set -eu; \ # uv creates the Hermes virtual environment without pip. Seed pip from Python's # bundled ensurepip payload while this layer is still root, then verify the # sandbox can read but cannot modify the virtual environment used by the -# installer. The build-only wheel has the official package identity but no -# upstream payload; Dockerfile.base separately verifies the checksum-pinned -# official artifacts without distributing them. +# installer. The build-only probe wheel has no upstream payload; +# Dockerfile.base separately verifies the checksum-pinned official Hindsight +# artifacts without distributing them. RUN /opt/hermes/.venv/bin/python -I -m ensurepip --upgrade --default-pip \ && /opt/hermes/.venv/bin/python -I -m pip --version \ && chmod 644 /opt/hermes/.venv/.lock \ @@ -1365,12 +1376,13 @@ RUN /opt/hermes/.venv/bin/python -I -m ensurepip --upgrade --default-pip \ && venv_links="$(cat "$venv_links_file")" \ && rm -f "$venv_links_file" \ && expected_venv_links="$(printf '%s\n' \ - 'bin/python -> /usr/bin/python3' \ + 'bin/python -> /usr/bin/python3.13' \ 'bin/python3 -> python' \ 'bin/python3.13 -> python' \ "lib/python3.13/site-packages/certifi/cacert.pem -> $SSL_CERT_FILE" \ 'lib64 -> lib')" \ - && test "$venv_links" = "$expected_venv_links" \ + && { test "$venv_links" = "$expected_venv_links" \ + || { echo "ERROR: Hermes virtual environment link inventory mismatch" >&2; exit 1; }; } \ && test "$(readlink -e /opt/hermes/.venv/bin/python)" = "/usr/bin/python3.13" \ && test "$(readlink -e /opt/hermes/.venv/bin/python3)" = "/usr/bin/python3.13" \ && test "$(readlink -e /opt/hermes/.venv/bin/python3.13)" = "/usr/bin/python3.13" \ @@ -1391,6 +1403,9 @@ RUN /opt/hermes/.venv/bin/python -I -m ensurepip --upgrade --default-pip \ exit 0' \ && test ! -e /opt/hermes/.venv/lib/.nemoclaw-sandbox-write-probe \ && install -d -o root -g root -m 0755 /tmp/nemoclaw-hindsight-probe \ + && cp /opt/nemoclaw-hermes-config/hindsight-probe-aiohttp-retry.whl \ + /tmp/nemoclaw-hindsight-probe/aiohttp_retry-2.9.1-py3-none-any.whl \ + && rm /opt/nemoclaw-hermes-config/hindsight-probe-aiohttp-retry.whl \ && /opt/hermes/.venv/bin/python -I -c \ "import zipfile; entries = {'nemoclaw_lazy_probe/__init__.py': 'NEMOCLAW_BUILD_PROBE_FIXTURE = True\n', 'nemoclaw_lazy_probe-1.0.0.dist-info/METADATA': 'Metadata-Version: 2.4\nName: nemoclaw-lazy-probe\nVersion: 1.0.0\nSummary: NemoClaw offline lazy-installer build probe\n\n', 'nemoclaw_lazy_probe-1.0.0.dist-info/WHEEL': 'Wheel-Version: 1.0\nGenerator: NemoClaw build probe\nRoot-Is-Purelib: true\nTag: py3-none-any\n', 'nemoclaw_lazy_probe-1.0.0.dist-info/RECORD': 'nemoclaw_lazy_probe/__init__.py,,\nnemoclaw_lazy_probe-1.0.0.dist-info/METADATA,,\nnemoclaw_lazy_probe-1.0.0.dist-info/WHEEL,,\nnemoclaw_lazy_probe-1.0.0.dist-info/RECORD,,\n'}; wheel = zipfile.ZipFile('/tmp/nemoclaw-hindsight-probe/nemoclaw_lazy_probe-1.0.0-py3-none-any.whl', 'w', compression=zipfile.ZIP_DEFLATED); [wheel.writestr(name, data) for name, data in entries.items()]; wheel.close()" \ && chown root:root /tmp/nemoclaw-hindsight-probe/nemoclaw_lazy_probe-1.0.0-py3-none-any.whl \ @@ -1442,14 +1457,14 @@ RUN /opt/hermes/.venv/bin/python -I -m ensurepip --upgrade --default-pip \ && chmod 3770 /sandbox/.hermes \ && test "$(stat -c '%U:%G %a' /sandbox/.hermes)" = "sandbox:sandbox 3770" -# Prove the session and cron ledger contracts across the real image identities. +# Prove the session, cron, and Discord ledger contracts across the real image identities. # Gateway creates state.db in the writable runtime boundary. When the filesystem # supports WAL, SQLite also creates WAL/SHM sidecars; otherwise Hermes explicitly # falls back to DELETE mode. The patched SessionDB normalizes only the fixed files # SQLite selected to 0660, so sandbox can list and append the session in either -# mode. The scheduler then creates its separate WAL-backed ledger, sandbox -# online-copies it, and gateway reopens the restored replacement. Keep both -# sequences in one RUN to stay below Docker's layer cap. +# mode. The scheduler and Discord adapter then exercise their separate ledgers +# across both identities. Keep the sequences in one RUN to stay below Docker's +# layer cap. RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/runtime)" = "gateway:sandbox 2770" \ && test "$(readlink /sandbox/.hermes/state.db)" = "runtime/state.db" \ && test ! -e /sandbox/.hermes/runtime/state.db \ @@ -1468,6 +1483,10 @@ RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/runtime)" = "gateway:sandbox 277 && test ! -e /sandbox/.hermes/runtime/state.db \ && test ! -L /sandbox/.hermes/runtime/state.db \ && test ! -e /sandbox/.hermes/cron/executions.db \ + && HERMES_HOME=/sandbox/.hermes /usr/bin/setpriv --reuid=gateway --regid=gateway --init-groups -- /opt/hermes/.venv/bin/python -I \ + /opt/nemoclaw-hermes-config/image-build-probes.py secure-directory-modes \ + && test "$(stat -c '%U:%G %a' /sandbox/.hermes)" = "sandbox:sandbox 3770" \ + && test "$(stat -c '%U:%G %a' /sandbox/.hermes/runtime)" = "gateway:sandbox 2770" \ && umask 0007 \ && HERMES_HOME=/sandbox/.hermes /usr/bin/setpriv --reuid=gateway --regid=gateway --init-groups -- /opt/hermes/.venv/bin/python -I \ /opt/nemoclaw-hermes-config/image-build-probes.py cron-create \ @@ -1482,13 +1501,8 @@ RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/runtime)" = "gateway:sandbox 277 && test "$(stat -c '%U:%G %a' /sandbox/.hermes/runtime/cron-executions.db)" = "sandbox:sandbox 660" \ && rm -f /sandbox/.hermes/runtime/cron-executions.db \ /sandbox/.hermes/runtime/cron-executions.db-wal \ - /sandbox/.hermes/runtime/cron-executions.db-shm - -# Prove the Discord ledger contract across the real image identities. The -# gateway creates the live WAL-backed database, sandbox reads and online-copies -# it before atomically restoring a sandbox-owned replacement, and gateway then -# reopens that replacement for a write. -RUN test "$(stat -c '%U:%G %a' /sandbox/.hermes/gateway)" = "gateway:sandbox 2770" \ + /sandbox/.hermes/runtime/cron-executions.db-shm \ + && test "$(stat -c '%U:%G %a' /sandbox/.hermes/gateway)" = "gateway:sandbox 2770" \ && umask 0007 \ && HERMES_HOME=/sandbox/.hermes /usr/bin/setpriv --reuid=gateway --regid=gateway --init-groups -- /opt/hermes/.venv/bin/python -I \ /opt/nemoclaw-hermes-config/image-build-probes.py discord-create \ @@ -1576,6 +1590,19 @@ RUN check_metadata() { \ }; \ check_absent /opt/hermes/tests \ && check_absent /opt/nemoclaw-hermes-config/image-build-probes.py \ + && check_absent /opt/nemoclaw-hermes-config/finalize-image-layout.sh \ + && check_absent /opt/nemoclaw-hermes-config/patch-gateway-runtime-metadata.py \ + && check_absent /opt/nemoclaw-hermes-config/patch-gateway-process-identity.py \ + && check_absent /opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py \ + && check_absent /opt/nemoclaw-hermes-config/patch-cron-restore-drain.py \ + && check_absent /opt/nemoclaw-hermes-config/patch-session-list-preview.py \ + && check_absent /opt/nemoclaw-hermes-config/patch-profile-policy-defaults.py \ + && check_absent /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py \ + && check_absent /opt/nemoclaw-hermes-config/hindsight-lazy-integrity.patch \ + && check_absent /opt/nemoclaw-hermes-config/hindsight-probe-aiohttp-retry.whl \ + && check_absent /opt/nemoclaw-hermes-config/managed_policy.py \ + && check_absent /usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py \ + && check_absent /usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py \ && check_absent /root/.npm \ && check_absent /root/.cache/electron \ && check_absent /root/.cache/node-gyp \ @@ -1598,9 +1625,9 @@ RUN check_metadata() { \ && check_metadata /scripts/lib/patch-bundled-npm-ip-address.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 /opt/nemoclaw-hermes-config/hindsight-client-requirements.txt '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' \ && test ! -L /usr/local/bin/nemoclaw-managed-bootstrap \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 70794353ecb..33ca870cd5d 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -63,6 +63,7 @@ FROM node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35f COPY --from=perl-builder /out /tmp/nemoclaw-native-security ENV DEBIAN_FRONTEND=noninteractive +ENV HERMES_SKIP_CHMOD=1 # Hermes version pinned for reproducibility. All four values below are # managed by scripts/update-hermes-agent.sh — HERMES_VERSION is the GitHub @@ -70,15 +71,18 @@ ENV DEBIAN_FRONTEND=noninteractive # pyproject.toml, HERMES_TARBALL_SHA256 the GitHub tarball checksum, and # HERMES_NPM_INTEGRITY the `npm view hermes-agent@ dist.integrity` # sha512 used as a registry cross-check at build time. -# Calver tag v2026.7.20 = Hermes Agent v0.19.0. -ARG HERMES_VERSION=v2026.7.20 -ARG HERMES_SEMVER=0.19.0 -ARG HERMES_TARBALL_SHA256=285f3fc134ff466a90065e1517801a68993733b807158ee8f32aa01613786990 -ARG HERMES_NPM_INTEGRITY=sha512-+oVKG3lXbk2kEP+J6BXZjtmSBSaFfczIdOWQ9CUSTdTqq2uyHbk4p+kPyZ6MeGs56JU5qXzMNbqGKRVOQRGC1A== +# Calver tag v2026.8.27 = Hermes Agent v0.20.6. +ARG HERMES_VERSION=v2026.8.27 +ARG HERMES_SEMVER=0.20.6 +ARG HERMES_TARBALL_SHA256=e622723b5bf3cd6c1db974d92d32242f1cb63f61c1112b6f708b34d619ef0fc7 +ARG HERMES_NPM_INTEGRITY=sha512-s5q1IEBifCBb77QMwkse4MRaAaoZSxIa4IkicIO3jL7MIdq15YvnSyiNvsTOWNBi6t3shFpIg+H7+9MJsOiSkg== ARG HERMES_UV_EXTRAS="anthropic messaging web pty mcp acp" ARG NODE_VERSION=24.18.1 ARG UV_VERSION=0.11.33 -ARG NEMOCLAW_HERMES_RUNTIME_BOUNDARIES_PATCH_SHA256=9e41bed797965990bf7edf214c782c18c04d086a15042415ae7085a507873b1c +ARG NEMOCLAW_HERMES_RUNTIME_BOUNDARIES_PATCH_SHA256=820563cc3450de582663c8137670001bb9974ac8a5584901880982f6509747b8 +ARG NEMOCLAW_HERMES_SECURE_DIR_SOURCE_SHA256=ffae3271120cf53eb8a7f574f76758eac3ea172f9ddc22c1056277e37d8d392c +ARG NEMOCLAW_HERMES_SECURE_DIR_PATCH_SHA256=1c1a898226bc67b123ec72847d4dcda7abbab1e95958c9e019790d9425e15d69 +ARG NEMOCLAW_HERMES_SECURE_DIR_OUTPUT_SHA256=130cf4e76d6f4f16b85517cf8d3d81dfe294aff63d6c561b979971c401f78761 # build-essential: hermes-agent >= 0.16.0 ships npm dependencies that need a # node-gyp native build during `npm ci`; the runtime Dockerfile purges build @@ -425,7 +429,8 @@ RUN printf '%s\n' \ # final image when selected. # New Hermes integrations should be installed by the agent workflow when they # are enabled rather than shipped in the base image by default. -# Root Node dependencies provide Hermes browser tooling such as agent-browser. +# Hermes 0.20.6 keeps agent-browser outside its root dependency graph. The +# root-owned runtime below installs the pinned package from a reviewed lockfile. # The WhatsApp adapter ships a separate Node project under # scripts/whatsapp-bridge whose dependencies Hermes otherwise installs lazily # on the first `hermes whatsapp` run. That lazy `npm install` targets @@ -438,13 +443,21 @@ RUN printf '%s\n' \ # build. RUN pip3 install --no-cache-dir --break-system-packages "uv==${UV_VERSION}" \ && uv_version_output="$(uv --version)" \ - && uv_version="${uv_version_output#uv }" \ - && test "${uv_version%% *}" = "${UV_VERSION}" + && uv_command="${uv_version_output%% *}" \ + && uv_version_tail="${uv_version_output#* }" \ + && uv_version="${uv_version_tail%% *}" \ + && [ "$uv_version_tail" != "$uv_version_output" ] \ + && [ "$uv_command" = "uv" ] \ + && [ "$uv_version" = "$UV_VERSION" ] # Upstream tests are not part of the production runtime and can contain # intentionally hostile security-test fixtures. Remove them in the extraction # RUN so their bytes never enter a published image layer. COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch +COPY agents/hermes/agent-browser-runtime/package.json \ + agents/hermes/agent-browser-runtime/package-lock.json \ + /opt/nemoclaw-agent-browser-runtime/ COPY agents/hermes/runtime-boundaries.patch /tmp/hermes-runtime-boundaries.patch +COPY agents/hermes/secure-dir-skip-chmod.patch /tmp/hermes-secure-dir-skip-chmod.patch COPY agents/hermes/whatsapp-proxy.patch /tmp/hermes-whatsapp-proxy.patch COPY scripts/checks/download-hermes-source-archive.sh /tmp/nemoclaw-download-hermes-source-archive.sh # hadolint ignore=DL4006 @@ -454,11 +467,22 @@ RUN mkdir -p /opt/hermes \ && sha256sum -c /tmp/hermes.tar.gz.sha256 \ && tar -xzf /tmp/hermes.tar.gz -C /opt/hermes --strip-components=1 \ && rm -rf /opt/hermes/tests \ - && printf '%s %s\n' "$NEMOCLAW_HERMES_RUNTIME_BOUNDARIES_PATCH_SHA256" /tmp/hermes-runtime-boundaries.patch | sha256sum -c - \ + && printf '%s %s\n' \ + "$NEMOCLAW_HERMES_RUNTIME_BOUNDARIES_PATCH_SHA256" /tmp/hermes-runtime-boundaries.patch \ + "$NEMOCLAW_HERMES_SECURE_DIR_PATCH_SHA256" /tmp/hermes-secure-dir-skip-chmod.patch \ + "$NEMOCLAW_HERMES_SECURE_DIR_SOURCE_SHA256" /opt/hermes/hermes_cli/config.py \ + | sha256sum -c - \ + || { echo "ERROR: Hermes secure-directory patch or source identity mismatch" >&2; exit 1; } \ && git -C /opt/hermes apply --check /tmp/hermes-security-dependencies.patch \ && git -C /opt/hermes apply /tmp/hermes-security-dependencies.patch \ && git -C /opt/hermes apply --check /tmp/hermes-runtime-boundaries.patch \ && git -C /opt/hermes apply /tmp/hermes-runtime-boundaries.patch \ + && git -C /opt/hermes apply --check /tmp/hermes-secure-dir-skip-chmod.patch \ + && git -C /opt/hermes apply /tmp/hermes-secure-dir-skip-chmod.patch \ + && printf '%s %s\n' \ + "$NEMOCLAW_HERMES_SECURE_DIR_OUTPUT_SHA256" /opt/hermes/hermes_cli/config.py \ + | sha256sum -c - \ + || { echo "ERROR: Hermes secure-directory patched output identity mismatch" >&2; exit 1; } \ && grep -Fq 'def nemoclaw_managed_gateway_plugins_only()' /opt/hermes/hermes_constants.py \ && grep -Fq 'nemoclaw_protected_process_control' /opt/hermes/hermes_cli/env_loader.py \ && grep -Fq 'nemoclaw_sanitized_installer_env' /opt/hermes/tools/lazy_deps.py \ @@ -470,6 +494,7 @@ RUN mkdir -p /opt/hermes \ && git -C /opt/hermes apply /tmp/hermes-whatsapp-proxy.patch \ && rm /tmp/hermes.tar.gz /tmp/hermes.tar.gz.sha256 \ /tmp/hermes-security-dependencies.patch /tmp/hermes-runtime-boundaries.patch \ + /tmp/hermes-secure-dir-skip-chmod.patch \ /tmp/hermes-whatsapp-proxy.patch \ /tmp/nemoclaw-download-hermes-source-archive.sh @@ -512,16 +537,20 @@ WORKDIR /opt/hermes # workspaces disabled; this keeps browser tooling without either UI build tree. # Root npm, Electron, and node-gyp caches are build-only. Remove them in the # same RUN that creates them so Docker cannot retain their bytes in this layer. +# Hermes ships a `.python-version` for Python 3.11. Use the reviewed system +# Python 3.13 directly and forbid a root-private uv-managed interpreter so the +# finished virtual environment remains executable by the sandbox user. # hadolint ignore=SC2086 RUN set -eu; \ set --; \ for extra in ${HERMES_UV_EXTRAS}; do \ set -- "$@" --extra "$extra"; \ done; \ - uv sync --frozen --no-dev "$@" --no-cache \ + uv sync --python /usr/bin/python3.13 --no-managed-python \ + --frozen --no-dev "$@" --no-cache \ && uv pip check --python /opt/hermes/.venv/bin/python \ && /opt/hermes/.venv/bin/python -I -c \ - "from importlib.metadata import version; expected = {'agent-client-protocol': '0.9.0', 'aiohttp': '3.14.3', 'cryptography': '50.0.0', 'mcp': '1.28.1', 'pillow': '12.3.0', 'starlette': '1.3.1', 'tornado': '6.5.7'}; actual = {name: version(name) for name in expected}; assert actual == expected, actual" \ + "from importlib.metadata import version; expected = {'agent-client-protocol': '0.9.0', 'aiohttp': '3.14.3', 'cryptography': '50.0.0', 'mcp': '2.0.0', 'pillow': '12.3.0', 'starlette': '1.3.1', 'tornado': '6.5.7'}; actual = {name: version(name) for name in expected}; assert actual == expected, actual" \ && npm ci --prefer-offline --no-audit --no-fund \ && for ui_dir in ui-tui web; do \ if [ -f "${ui_dir}/package-lock.json" ]; then \ @@ -563,11 +592,11 @@ RUN set -eu; \ # route that uses File/Form, so without python-multipart the plugin's API routes # fail to mount ("Form data requires python-multipart to be installed"). # -# Hermes 0.19.0 resolves 0.0.27, which is affected by the network-reachable -# GHSA-5rvq-cxj2-64vf CPU denial of service and GHSA-6jv3-5f52-599m parser -# differential. Keep this hash-verified downstream override at the first stable -# release that fixes those issues plus GHSA-v9pg-7xvm-68hf. Re-review the -# version and both hashes on every Hermes version bump. +# Hermes 0.20.6 resolves 0.0.32, the first stable release that addresses the +# reviewed network-reachable GHSA-5rvq-cxj2-64vf CPU denial of service, +# GHSA-6jv3-5f52-599m parser differential, and GHSA-v9pg-7xvm-68hf. Keep the +# hash-verified install so the final image cannot inherit a stale published +# base. Re-review the version and both hashes on every Hermes version bump. # uv creates a phony .git cache marker even with --no-cache. Remove the cache # after the final uv command because the root cache is not used at runtime. # hadolint ignore=DL3059 @@ -583,6 +612,10 @@ RUN printf '%s\n' \ "import multipart; assert multipart.__version__ == '0.0.32', multipart.__version__" \ && rm -rf /root/.cache/uv +# The offline lazy-package probe runs as the sandbox user. Make the finalized +# virtual environment readable before that unprivileged build-time check. +RUN chmod -R a+rX /opt/hermes/.venv + # Keep official-package compatibility separate from the final image's offline # lazy-installer contract. BuildKit verifies both reviewed PyPI artifacts before # any build command can consume them. Install only those hash-locked wheels into @@ -621,12 +654,53 @@ RUN --network=none install -d -o sandbox -g sandbox -m 0750 \ ENV PATH="/usr/local/bin:/opt/hermes/.venv/bin:${PATH}" \ HERMES_TUI_DIR="/opt/hermes/ui-tui" \ HERMES_WEB_DIST="/opt/hermes/hermes_cli/web_dist" + +# Hermes 0.20.6 removed agent-browser from its root dependency graph. Install +# the reviewed lockfile into an immutable root-owned runtime so browser +# credentials never cross a sandbox-writable executable boundary. +RUN HOME=/sandbox \ + /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + /opt/hermes/.venv/bin/python -c \ + 'from tools import browser_tool; assert browser_tool.AGENT_BROWSER_NPX_SPEC == "agent-browser@0.26.0"; assert browser_tool._find_agent_browser() == browser_tool.NPX_AGENT_BROWSER_SENTINEL' +RUN install -o root -g root -m 0400 /dev/null \ + /tmp/nemoclaw-agent-browser-userconfig \ + && install -o root -g root -m 0400 /dev/null \ + /tmp/nemoclaw-agent-browser-globalconfig \ + && HOME=/root npm_config_cache=/root/.npm \ + npm_config_registry=https://registry.npmjs.org/ \ + npm_config_userconfig=/tmp/nemoclaw-agent-browser-userconfig \ + npm_config_globalconfig=/tmp/nemoclaw-agent-browser-globalconfig \ + /usr/local/bin/npm ci \ + --prefix /opt/nemoclaw-agent-browser-runtime \ + --ignore-scripts --no-audit --no-fund \ + && agent_browser_install_version="$( \ + /opt/nemoclaw-agent-browser-runtime/node_modules/.bin/agent-browser --version \ + )" \ + && test "$agent_browser_install_version" = 'agent-browser 0.26.0' \ + && chown -R root:root /opt/nemoclaw-agent-browser-runtime \ + && chmod -R go-w /opt/nemoclaw-agent-browser-runtime \ + && ln -s /opt/nemoclaw-agent-browser-runtime/node_modules/.bin/agent-browser \ + /usr/local/bin/agent-browser \ + && rm -rf /root/.npm \ + /tmp/nemoclaw-agent-browser-userconfig \ + /tmp/nemoclaw-agent-browser-globalconfig +RUN --network=none agent_browser_version="$( \ + /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + /usr/local/bin/agent-browser --version \ + )" \ + && test "${agent_browser_version}" = 'agent-browser 0.26.0' \ + && test "$(readlink /usr/local/bin/agent-browser)" = \ + '/opt/nemoclaw-agent-browser-runtime/node_modules/.bin/agent-browser' \ + && ! /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + /bin/sh -c 'printf unsafe >> /opt/nemoclaw-agent-browser-runtime/node_modules/agent-browser/bin/agent-browser.js' \ + && ! /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + /bin/sh -c 'rm /usr/local/bin/agent-browser' \ + && HOME=/sandbox /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- \ + /opt/hermes/.venv/bin/python -c \ + 'from tools import browser_tool; assert browser_tool._find_agent_browser() == "/usr/local/bin/agent-browser"' + RUN /usr/local/bin/hermes --version \ && /usr/local/bin/hermes acp --check \ - && test -x /opt/hermes/node_modules/.bin/agent-browser \ - && /opt/hermes/node_modules/.bin/agent-browser --version \ - && /opt/hermes/.venv/bin/python -c \ - 'from tools import browser_tool; expected = "/opt/hermes/node_modules/.bin/agent-browser"; assert browser_tool._find_agent_browser() == expected' \ && test -s "${HERMES_TUI_DIR}/dist/entry.js" \ && test -s "${HERMES_WEB_DIST}/index.html" \ && test ! -e /opt/hermes/.node_modules.runtime \ @@ -636,10 +710,9 @@ RUN /usr/local/bin/hermes --version \ && rmdir /opt/hermes/node_modules \ && mv /opt/hermes/.node_modules.runtime /opt/hermes/node_modules \ && /opt/hermes/.venv/bin/python -c \ - 'import mcp; from tools import mcp_tool; assert getattr(mcp_tool, "_MCP_AVAILABLE", False), "Hermes MCP client runtime is unavailable"; assert getattr(mcp_tool, "_MCP_HTTP_AVAILABLE", False), "Hermes MCP Streamable HTTP runtime is unavailable"' + 'import mcp; from tools import mcp_tool; assert mcp_tool._ensure_mcp_sdk(), "Hermes MCP client runtime is unavailable"; assert getattr(mcp_tool, "_MCP_AVAILABLE", False), "Hermes MCP client runtime is unavailable"; assert getattr(mcp_tool, "_MCP_HTTP_AVAILABLE", False), "Hermes MCP Streamable HTTP runtime is unavailable"' -RUN chmod -R a+rX /opt/hermes/.venv \ - && test -r /opt/hermes/.venv/pyvenv.cfg \ +RUN test -r /opt/hermes/.venv/pyvenv.cfg \ && su -s /bin/sh sandbox -c '/opt/hermes/.venv/bin/python3 -c "import urllib.request"' # Reject build-only paths before the base image can be published. diff --git a/agents/hermes/a2a-neutral.patch b/agents/hermes/a2a-neutral.patch new file mode 100644 index 00000000000..eef9dfd4673 --- /dev/null +++ b/agents/hermes/a2a-neutral.patch @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# NemoClaw has no accepted product scope for Hermes A2A. Keep its inbound +# adapter discoverable but disabled, and remove its outbound tool registration. +diff --git a/plugins/platforms/a2a/plugin.yaml b/plugins/platforms/a2a/plugin.yaml +--- a/plugins/platforms/a2a/plugin.yaml ++++ b/plugins/platforms/a2a/plugin.yaml +@@ -27,15 +27,5 @@ description: > + Pure stdlib transport (http.server + urllib) — no a2a-sdk dependency required. + author: Nous Research +-# The outbound client tools. Declaring them here is what asks discovery to +-# import `tools.py` in CLI/TUI processes, where the plugin is otherwise +-# deferred and the tools would never register at all (#78050). The inbound +-# adapter stays deferred either way — only this submodule is imported. +-provides_tools: +- - a2a_discover +- - a2a_call +- - a2a_list +- - a2a_history +- - a2a_orchestrate + # requires_env / optional_env are surfaced in the `hermes config` UI via the + # platform-plugin env var injector in hermes_cli/config.py. + requires_env: [] +diff --git a/plugins/platforms/a2a/__init__.py b/plugins/platforms/a2a/__init__.py +--- a/plugins/platforms/a2a/__init__.py ++++ b/plugins/platforms/a2a/__init__.py +@@ -97,14 +97,6 @@ def interactive_setup() -> None: + def register(ctx) -> None: + """Plugin entry point — called by the Hermes plugin system.""" +- # 1) Client tools (outbound). Registering these even when the inbound +- # platform is disabled lets the agent call peers without exposing itself. +- try: +- from .tools import register_tools +- register_tools(ctx) +- except Exception: +- logger.warning("A2A: failed to register client tools", exc_info=True) +- +- # 2) Inbound platform adapter. ++ # The managed policy keeps this inbound adapter disabled. + try: + from .adapter import A2AAdapter + ctx.register_platform( diff --git a/agents/hermes/agent-browser-runtime/package-lock.json b/agents/hermes/agent-browser-runtime/package-lock.json new file mode 100644 index 00000000000..5d0a16cdf4c --- /dev/null +++ b/agents/hermes/agent-browser-runtime/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "nemoclaw-hermes-agent-browser-runtime", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nemoclaw-hermes-agent-browser-runtime", + "version": "1.0.0", + "dependencies": { + "agent-browser": "0.26.0" + } + }, + "node_modules/agent-browser": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/agent-browser/-/agent-browser-0.26.0.tgz", + "integrity": "sha512-pdqSfjwbFSp+qnwlb2g23e9wXveIOfMi19xpPA9xZUbzEAUp6W4YBZj6Ybj8z4M7WkcbGDDYc+oDIHDt9R3EDQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "agent-browser": "bin/agent-browser.js" + } + } + } +} diff --git a/agents/hermes/agent-browser-runtime/package.json b/agents/hermes/agent-browser-runtime/package.json new file mode 100644 index 00000000000..a5e4e527c8f --- /dev/null +++ b/agents/hermes/agent-browser-runtime/package.json @@ -0,0 +1,9 @@ +{ + "name": "nemoclaw-hermes-agent-browser-runtime", + "version": "1.0.0", + "private": true, + "license": "Apache-2.0", + "dependencies": { + "agent-browser": "0.26.0" + } +} diff --git a/agents/hermes/config/managed-policy.ts b/agents/hermes/config/managed-policy.ts index 0e0e5da39bd..287b29ba380 100644 --- a/agents/hermes/config/managed-policy.ts +++ b/agents/hermes/config/managed-policy.ts @@ -49,12 +49,14 @@ export const MANAGED_IMAGE_HERMES_SUPPORTED_PLATFORMS = [ "google_chat", ] as const; -// Hermes v0.19.0 also packages platform plugins and built-in adapters that are +// Hermes v0.20.6 also packages platform plugins and built-in adapters that are // not yet supported by NemoClaw's messaging manifests. A neutral managed image // must explicitly disable the complete installed surface, while keeping this // list separate from the supported/activatable contract above. export const MANAGED_IMAGE_HERMES_NEUTRAL_PLATFORMS = [ + "a2a", "bluebubbles", + "buzz", "dingtalk", "discord", "email", diff --git a/agents/hermes/cron-restore-control.py b/agents/hermes/cron-restore-control.py index 9d4af474166..446b3759f71 100644 --- a/agents/hermes/cron-restore-control.py +++ b/agents/hermes/cron-restore-control.py @@ -11,9 +11,10 @@ persisted drain marker. Before release, the controller durably writes a separate root-owned recovery -record. That write-ahead record survives a failed marker rollback and lets -``prepare-recover`` reacquire the gate before host gateway repair. ``recover`` -then validates cron state before clearing NemoClaw-owned recovery state. +record with the original gate-acquisition time. That write-ahead record survives +a failed marker rollback and lets ``prepare-recover`` reacquire the same gate +before host gateway repair. ``recover`` then validates cron state before +clearing NemoClaw-owned recovery state. """ from __future__ import annotations @@ -29,6 +30,7 @@ import tempfile import time from contextlib import contextmanager +from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterator @@ -169,7 +171,12 @@ def _validate_marker_metadata(metadata: os.stat_result, label: str) -> None: raise ControlError(f"{label} size is invalid") -def _read_owned_token(path: Path, label: str, *, required: bool) -> str | None: +def _read_owned_payload( + path: Path, + label: str, + *, + required: bool, +) -> tuple[dict[str, Any], os.stat_result] | None: _require_secure_directory(NEMOCLAW_HOME, "NemoClaw state root") flags = os.O_RDONLY | os.O_CLOEXEC if hasattr(os, "O_NOFOLLOW"): @@ -194,12 +201,15 @@ def _read_owned_token(path: Path, label: str, *, required: bool) -> str | None: payload = json.loads(raw.decode("utf-8")) except (UnicodeError, ValueError) as error: raise ControlError(f"{label} is invalid") from error - if not isinstance(payload, dict) or set(payload) != {"token", "version"}: + if not isinstance(payload, dict): raise ControlError(f"{label} has an invalid schema") + return payload, metadata + + +def _validate_drain_token(payload: dict[str, Any], label: str) -> str: token = payload.get("token") if ( - payload.get("version") != 1 - or not isinstance(token, str) + not isinstance(token, str) or len(token) != 32 or not token.isascii() or not all(character.isalnum() or character in "-_" for character in token) @@ -208,6 +218,26 @@ def _read_owned_token(path: Path, label: str, *, required: bool) -> str | None: return token +def _read_owned_token_record( + path: Path, + label: str, + *, + required: bool, +) -> tuple[str, os.stat_result] | None: + observed = _read_owned_payload(path, label, required=required) + if observed is None: + return None + payload, metadata = observed + if set(payload) != {"token", "version"} or payload.get("version") != 1: + raise ControlError(f"{label} has an invalid schema") + return _validate_drain_token(payload, label), metadata + + +def _read_owned_token(path: Path, label: str, *, required: bool) -> str | None: + observed = _read_owned_token_record(path, label, required=required) + return None if observed is None else observed[0] + + def _read_owned_drain_token(*, required: bool = True) -> str | None: return _read_owned_token( _marker_path(), @@ -216,12 +246,47 @@ def _read_owned_drain_token(*, required: bool = True) -> str | None: ) -def _read_release_recovery_token(*, required: bool = True) -> str | None: - return _read_owned_token( +def _read_release_recovery( + *, + required: bool = True, +) -> tuple[str, int] | None: + observed = _read_owned_payload( _release_recovery_path(), "NemoClaw cron restore release recovery record", required=required, ) + if observed is None: + return None + payload, _metadata = observed + if ( + set(payload) != {"drain_started_at_ns", "token", "version"} + or payload.get("version") != 2 + ): + raise ControlError( + "NemoClaw cron restore release recovery record has an invalid schema" + ) + started_at_ns = payload.get("drain_started_at_ns") + if ( + isinstance(started_at_ns, bool) + or not isinstance(started_at_ns, int) + or started_at_ns <= 0 + or started_at_ns > (1 << 63) - 1 + ): + raise ControlError( + "NemoClaw cron restore release recovery record has an invalid drain time" + ) + return ( + _validate_drain_token( + payload, + "NemoClaw cron restore release recovery record", + ), + started_at_ns, + ) + + +def _read_release_recovery_token(*, required: bool = True) -> str | None: + observed = _read_release_recovery(required=required) + return None if observed is None else observed[0] def _require_owned_token( @@ -246,21 +311,56 @@ def _require_owned_drain(drain_token: str) -> None: ) -def _write_owned_token( +def _owned_drain_started_at_ns(drain_token: str) -> int: + """Return the authenticated drain marker's durable creation time.""" + observed = _read_owned_token_record( + _marker_path(), + "NemoClaw cron restore drain marker", + required=True, + ) + if observed is None: + raise ControlError("NemoClaw cron restore drain marker is not active") + observed_token, metadata = observed + if not hmac.compare_digest(observed_token, drain_token): + raise ControlError("NemoClaw cron restore drain ownership changed") + return metadata.st_mtime_ns + + +def _owned_drain_started_at(drain_token: str) -> datetime: + started_at_ns = _owned_drain_started_at_ns(drain_token) + return datetime.fromtimestamp(started_at_ns / 1_000_000_000, timezone.utc) + + +def _rearm_drained_oneshots(drain_token: str) -> int: + """Re-arm only one-shots due at or after this drain was acquired.""" + started_at = _owned_drain_started_at(drain_token) + try: + from cron.jobs import rearm_nemoclaw_drained_oneshots + except Exception as error: + raise ControlError("Hermes cron restore re-arm helper is unavailable") from error + try: + profile_homes = [profile_home for _label, profile_home in _profile_homes(HERMES_HOME)] + changed = rearm_nemoclaw_drained_oneshots(started_at, profile_homes) + except Exception as error: + raise ControlError("Hermes cron restore could not re-arm delayed one-shots") from error + if isinstance(changed, bool) or not isinstance(changed, int) or changed < 0: + raise ControlError("Hermes cron restore re-arm result is invalid") + return changed + + +def _write_owned_record( path: Path, label: str, - drain_token: str, + payload: bytes, *, temp_prefix: str, exists_message: str, write_message: str, + mtime_ns: int | None = None, ) -> None: _require_secure_directory(NEMOCLAW_HOME, "NemoClaw state root") - payload = json.dumps( - {"token": drain_token, "version": 1}, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") + if not payload or len(payload) > MAX_MARKER_BYTES: + raise ControlError(f"{label} payload is invalid") descriptor = -1 staged_path: Path | None = None try: @@ -274,6 +374,8 @@ def _write_owned_token( written = os.write(descriptor, payload) if written != len(payload): raise OSError("short marker write") + if mtime_ns is not None: + os.utime(descriptor, ns=(mtime_ns, mtime_ns)) os.fsync(descriptor) os.close(descriptor) descriptor = -1 @@ -283,7 +385,6 @@ def _write_owned_token( raise ControlError(exists_message) from error staged_path.unlink() staged_path = None - _require_owned_token(path, label, label, drain_token) _fsync_directory(NEMOCLAW_HOME, "NemoClaw state root") except ControlError: raise @@ -296,45 +397,87 @@ def _write_owned_token( staged_path.unlink(missing_ok=True) -def _write_owned_drain(drain_token: str) -> None: - _write_owned_token( +def _write_owned_drain(drain_token: str, *, started_at_ns: int | None = None) -> None: + payload = json.dumps( + {"token": drain_token, "version": 1}, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + _write_owned_record( _marker_path(), "NemoClaw cron restore drain marker", - drain_token, + payload, temp_prefix=".hermes-cron-restore-drain-", exists_message="a NemoClaw cron restore drain already requires recovery", write_message="NemoClaw cron restore drain could not be acquired", + mtime_ns=started_at_ns, ) + _require_owned_drain(drain_token) + if started_at_ns is not None and _owned_drain_started_at_ns(drain_token) != started_at_ns: + raise ControlError("NemoClaw cron restore drain time changed") -def _write_release_recovery(drain_token: str) -> None: - _write_owned_token( +def _write_release_recovery(drain_token: str, drain_started_at_ns: int) -> None: + payload = json.dumps( + { + "drain_started_at_ns": drain_started_at_ns, + "token": drain_token, + "version": 2, + }, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + _write_owned_record( _release_recovery_path(), "NemoClaw cron restore release recovery record", - drain_token, + payload, temp_prefix=".hermes-cron-restore-release-recovery-", exists_message="a NemoClaw cron restore release recovery already exists", write_message="NemoClaw cron restore release recovery could not be recorded", ) + observed = _read_release_recovery(required=True) + if observed != (drain_token, drain_started_at_ns): + raise ControlError("NemoClaw cron restore release recovery record changed") def _ensure_release_recovery(drain_token: str) -> None: - observed_token = _read_release_recovery_token(required=False) - if observed_token is None: - _write_release_recovery(drain_token) + drain_started_at_ns = _owned_drain_started_at_ns(drain_token) + observed = _read_release_recovery(required=False) + if observed is None: + _write_release_recovery(drain_token, drain_started_at_ns) return + observed_token, observed_started_at_ns = observed if not hmac.compare_digest(observed_token, drain_token): raise ControlError("NemoClaw cron restore release recovery ownership changed") + if observed_started_at_ns != drain_started_at_ns: + raise ControlError("NemoClaw cron restore release recovery drain time changed") _fsync_directory(NEMOCLAW_HOME, "NemoClaw state root") -def _ensure_owned_drain(drain_token: str) -> None: +def _ensure_owned_drain( + drain_token: str, + *, + started_at_ns: int | None = None, +) -> None: observed_token = _read_owned_drain_token(required=False) if observed_token is None: - _write_owned_drain(drain_token) + recovery = _read_release_recovery(required=started_at_ns is None) + if recovery is not None: + recovery_token, recovery_started_at_ns = recovery + if not hmac.compare_digest(recovery_token, drain_token): + raise ControlError("NemoClaw cron restore release recovery ownership changed") + if started_at_ns is None: + started_at_ns = recovery_started_at_ns + elif started_at_ns != recovery_started_at_ns: + raise ControlError("NemoClaw cron restore release recovery drain time changed") + if started_at_ns is None: + raise ControlError("NemoClaw cron restore drain time is unavailable") + _write_owned_drain(drain_token, started_at_ns=started_at_ns) return if not hmac.compare_digest(observed_token, drain_token): raise ControlError("NemoClaw cron restore drain ownership changed") + if started_at_ns is not None and _owned_drain_started_at_ns(drain_token) != started_at_ns: + raise ControlError("NemoClaw cron restore drain time changed") _fsync_directory(NEMOCLAW_HOME, "NemoClaw state root") @@ -365,13 +508,19 @@ def _remove_owned_drain(drain_token: str) -> None: def _remove_release_recovery(drain_token: str) -> None: - _remove_owned_token( - _release_recovery_path(), - "NemoClaw cron restore release recovery record", - "NemoClaw cron restore release recovery record", - drain_token, - failure_message="NemoClaw cron restore release recovery could not be cleared", - ) + observed = _read_release_recovery(required=True) + if observed is None: + raise ControlError("NemoClaw cron restore release recovery record is not active") + observed_token, _drain_started_at_ns = observed + if not hmac.compare_digest(observed_token, drain_token): + raise ControlError("NemoClaw cron restore release recovery ownership changed") + try: + _release_recovery_path().unlink() + except OSError as error: + raise ControlError( + "NemoClaw cron restore release recovery could not be cleared" + ) from error + _fsync_directory(NEMOCLAW_HOME, "NemoClaw state root") def _profile_homes(home: Path) -> list[tuple[str, Path]]: @@ -659,12 +808,16 @@ def _complete_release( **fields: Any, ) -> None: _require_drained_idle(status_module, pid, start_time) + drain_started_at_ns = _owned_drain_started_at_ns(drain_token) _ensure_release_recovery(drain_token) + rearmed_oneshots = 0 + if not _operator_drain_active(drain_control): + rearmed_oneshots = _rearm_drained_oneshots(drain_token) try: _remove_owned_drain(drain_token) except ControlError as release_error: try: - _ensure_owned_drain(drain_token) + _ensure_owned_drain(drain_token, started_at_ns=drain_started_at_ns) except ControlError as rollback_error: raise ControlError( "Hermes cron restore drain release failed and its marker could not be restored", @@ -680,7 +833,7 @@ def _complete_release( ) except Exception as release_error: try: - _ensure_owned_drain(drain_token) + _ensure_owned_drain(drain_token, started_at_ns=drain_started_at_ns) except ControlError as rollback_error: raise ControlError( "Hermes cron restore drain release failed and its marker could not be restored", @@ -693,7 +846,7 @@ def _complete_release( _remove_release_recovery(drain_token) except ControlError as cleanup_error: try: - _ensure_owned_drain(drain_token) + _ensure_owned_drain(drain_token, started_at_ns=drain_started_at_ns) except ControlError as rollback_error: raise ControlError( "Hermes cron restore drain release failed and its marker could not be restored", @@ -712,21 +865,28 @@ def _complete_release( disposition=disposition, operator_drain_active=operator_drain_active, preserved_drain=operator_drain_active, + rearmed_oneshots=rearmed_oneshots, **fields, ) def _prepare_owned_drain() -> str | None: drain_token = _read_owned_drain_token(required=False) - recovery_token = _read_release_recovery_token(required=False) - if drain_token is not None and recovery_token is not None: + recovery = _read_release_recovery(required=False) + if drain_token is not None and recovery is not None: + recovery_token, recovery_started_at_ns = recovery if not hmac.compare_digest(drain_token, recovery_token): raise ControlError( "NemoClaw cron restore drain and release recovery ownership differ" ) + if _owned_drain_started_at_ns(drain_token) != recovery_started_at_ns: + raise ControlError( + "NemoClaw cron restore drain and release recovery times differ" + ) _fsync_directory(NEMOCLAW_HOME, "NemoClaw state root") - elif drain_token is None and recovery_token is not None: - _write_owned_drain(recovery_token) + elif drain_token is None and recovery is not None: + recovery_token, recovery_started_at_ns = recovery + _write_owned_drain(recovery_token, started_at_ns=recovery_started_at_ns) drain_token = recovery_token elif drain_token is not None: _fsync_directory(NEMOCLAW_HOME, "NemoClaw state root") diff --git a/agents/hermes/finalize-image-layout.sh b/agents/hermes/finalize-image-layout.sh new file mode 100755 index 00000000000..1435e4d7029 --- /dev/null +++ b/agents/hermes/finalize-image-layout.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +sandbox_root="${1:-/sandbox}" +case "$sandbox_root" in + /*) ;; + *) + echo "ERROR: Hermes image layout root must be absolute: $sandbox_root" >&2 + exit 1 + ;; +esac +if [ "$sandbox_root" = "/" ]; then + echo "ERROR: Hermes image layout root must not be /" >&2 + exit 1 +fi + +sandbox_root="${sandbox_root%/}" +config_dir="$sandbox_root/.hermes" +data_dir="$sandbox_root/.hermes-data" +openclaw_dir="$sandbox_root/.openclaw" + +if [ -e "$openclaw_dir" ] || [ -L "$openclaw_dir" ]; then + echo "ERROR: Hermes base image contains retired OpenClaw state: $openclaw_dir" >&2 + exit 1 +fi + +mkdir -p "$config_dir" +if [ -L "$data_dir" ]; then + echo "ERROR: refusing legacy layout cleanup because $data_dir is a symlink" >&2 + exit 1 +fi + +if [ -d "$data_dir" ]; then + legacy_link="$(find "$data_dir" -type l -print -quit)" + if [ -n "$legacy_link" ]; then + echo "ERROR: refusing legacy layout cleanup because $legacy_link is a symlink" >&2 + exit 1 + fi + for entry in "$data_dir"/*; do + [ -e "$entry" ] || [ -L "$entry" ] || continue + name="$(basename "$entry")" + target="$config_dir/$name" + if [ -L "$target" ]; then + rm -f "$target" + fi + if [ -d "$entry" ]; then + mkdir -p "$target" + cp -a "$entry"/. "$target"/ + elif [ ! -e "$target" ]; then + cp -a "$entry" "$target" + fi + done + + data_real="$(readlink -f "$data_dir" 2>/dev/null || printf '%s' "$data_dir")" + while :; do + replaced_marker="$(mktemp)" + rm -f "$replaced_marker" + find "$config_dir" -type l -print | while IFS= read -r link; do + raw_target="$(readlink "$link" 2>/dev/null || true)" + resolved_target="$(readlink -f "$link" 2>/dev/null || true)" + legacy_target=0 + case "$raw_target" in "$data_real"/* | "$data_dir"/*) legacy_target=1 ;; esac + case "$resolved_target" in "$data_real"/* | "$data_dir"/*) legacy_target=1 ;; esac + if [ "$legacy_target" -eq 1 ]; then + copy_target="$resolved_target" + if [ -z "$copy_target" ] || { [ ! -e "$copy_target" ] && [ ! -L "$copy_target" ]; }; then + copy_target="$raw_target" + fi + if [ -d "$copy_target" ] && [ ! -L "$copy_target" ]; then + rm -f "$link" + mkdir -p "$link" + cp -a "$copy_target"/. "$link"/ + elif [ -e "$copy_target" ] || [ -L "$copy_target" ]; then + rm -f "$link" + cp -a "$copy_target" "$link" + else + echo "ERROR: legacy symlink target missing: $link -> ${raw_target:-$resolved_target}" >&2 + exit 1 + fi + : >"$replaced_marker" + fi + done + if [ ! -e "$replaced_marker" ]; then + rm -f "$replaced_marker" + break + fi + rm -f "$replaced_marker" + done + rm -rf "$data_dir" +fi diff --git a/agents/hermes/hermes-cli-adapter-v1.json b/agents/hermes/hermes-cli-adapter-v1.json index eb5eb0c7546..6043ab2f5ea 100644 --- a/agents/hermes/hermes-cli-adapter-v1.json +++ b/agents/hermes/hermes-cli-adapter-v1.json @@ -1,6 +1,6 @@ { "adapter_version": 1, - "upstream_cli_version": "0.19.0", + "upstream_cli_version": "0.20.6", "managed_commands": ["chat"], "session_name_coalescer": { "module": "hermes_cli.main", @@ -128,7 +128,7 @@ "hermes [(-p|--profile) PROFILE] ((-r|--resume) SESSION|(-c|--continue) [SESSION]) (-z|--oneshot) PROMPT [CHAT_OPTION ...]", "hermes [--profile=PROFILE] (--resume=SESSION|--continue[=SESSION]) --oneshot=PROMPT [CHAT_OPTION ...]" ], - "reason": "Hermes 0.19 stores a top-level resumed or continued one-shot turn in a new session instead of the selected or most recent session.", + "reason": "Hermes 0.20.6 stores a top-level resumed or continued one-shot turn in a new session instead of the selected or most recent session.", "source_fix_constraint": "The affected session persistence implementation belongs to Hermes. The durable source fix must land in Hermes before NemoClaw removes this translation.", "removal_condition": "Hermes appends top-level resumed and continued one-shot turns to the selected or most recent session." }, @@ -143,7 +143,7 @@ "hermes [TOP_LEVEL_OPTION ...] chat [CHAT_OPTION ...] MODEL_OPTION [CHAT_OPTION ...] PROVIDER_OPTION [CHAT_OPTION ...]" ], "ambiguity_rule": "A top-level session name must be one argv value. The adapter rejects separate provider and model flags after an unquoted multi-word session name because a later positional can be an upstream command.", - "reason": "Hermes 0.19 does not resolve the OpenShell credential placeholder when provider and model are separate.", + "reason": "Hermes 0.20.6 does not resolve the OpenShell credential placeholder when provider and model are separate.", "source_fix_constraint": "The affected credential-resolution implementation belongs to Hermes. The durable source fix must land in Hermes before NemoClaw removes this translation.", "removal_condition": "Hermes resolves OpenShell credential placeholders for separate provider and model flags." } diff --git a/agents/hermes/hermes-wrapper.py b/agents/hermes/hermes-wrapper.py index 09af3d4e173..ce4f99de9c7 100755 --- a/agents/hermes/hermes-wrapper.py +++ b/agents/hermes/hermes-wrapper.py @@ -784,12 +784,12 @@ def main(argv: list[str]) -> int: return 1 os.environ["HERMES_HOME"] = _MANAGED_HERMES_HOME os.environ["HERMES_BUNDLED_PLUGINS"] = _MANAGED_BUNDLED_PLUGINS + os.environ["HERMES_LAZY_INSTALL_TARGET"] = _GATEWAY_LAZY_INSTALL_TARGET os.environ["HOME"] = _MANAGED_HOME + _harden_root_separated_gateway_package_env() rc = _run_gateway_guard(guard_path) if rc != 0: return rc - if os.environ.get("HERMES_LAZY_INSTALL_TARGET") == _GATEWAY_LAZY_INSTALL_TARGET: - _harden_root_separated_gateway_package_env() try: adapter = _load_cli_adapter(_resolve_cli_adapter()) adapter_result, exec_argv = _adapt_cli_argv(argv, adapter) diff --git a/agents/hermes/hindsight-client-probe-requirements.txt b/agents/hermes/hindsight-client-probe-requirements.txt index 442476487a1..087d558c53e 100644 --- a/agents/hermes/hindsight-client-probe-requirements.txt +++ b/agents/hermes/hindsight-client-probe-requirements.txt @@ -1,8 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Build-only compatibility probe. Keep these hashes synchronized with the -# checksum-pinned PyPI wheels in Dockerfile.base. +# Production Hindsight lazy-install lock and base-image compatibility probe. +# Keep these hashes synchronized with the checksum-pinned PyPI wheels in +# Dockerfile.base. hindsight-client==0.6.1 \ --hash=sha256:9fdda176ab50f7cec8d7339c6608c148f0cd9ad7e65d9d76192f2db730bc330a aiohttp-retry==2.9.1 \ diff --git a/agents/hermes/hindsight-lazy-integrity.patch b/agents/hermes/hindsight-lazy-integrity.patch new file mode 100644 index 00000000000..41a64b682fd --- /dev/null +++ b/agents/hermes/hindsight-lazy-integrity.patch @@ -0,0 +1,48 @@ +diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py +index 4a71f70..1fe4b15 100644 +--- a/tools/lazy_deps.py ++++ b/tools/lazy_deps.py +@@ -385,3 +385,6 @@ _LAZY_TARGET_ENV = "HERMES_LAZY_INSTALL_TARGET" + _TARGET_STAMP_NAME = ".python-abi" + ++_NEMOCLAW_HINDSIGHT_REQUIREMENTS = Path( ++ "/opt/nemoclaw-hermes-config/hindsight-client-requirements.txt" ++) + +@@ -716,6 +719,18 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install + if not specs: + return _InstallResult(True, "", "") + ++ install_args = list(specs) ++ if specs == ("hindsight-client==0.6.1",): ++ # NemoClaw supports Hindsight through this lazy boundary. Bind both ++ # wheels to the reviewed hashes so a same-version artifact from an ++ # ambient index or find-links directory cannot enter durable state. ++ install_args = [ ++ "--no-deps", ++ "--require-hashes", ++ "--requirement", ++ str(_NEMOCLAW_HINDSIGHT_REQUIREMENTS), ++ ] ++ + target = _lazy_install_target() + constraints: Optional[Path] = None + +@@ -758,7 +773,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install + if uv_bin: + try: + r = subprocess.run( +- [uv_bin, "pip", "install", *target_args, *constraint_args, *specs], ++ [uv_bin, "pip", "install", *target_args, *constraint_args, *install_args], + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout, env=uv_env, + stdin=subprocess.DEVNULL, + creationflags=windows_hide_flags(), +@@ -802,7 +817,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install + + try: + r = subprocess.run( +- pip_cmd + ["install", *target_args, *constraint_args, *specs], ++ pip_cmd + ["install", *target_args, *constraint_args, *install_args], + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout, + stdin=subprocess.DEVNULL, + creationflags=windows_hide_flags(), diff --git a/agents/hermes/image-build-probes.py b/agents/hermes/image-build-probes.py index 62d9d2f8fe1..15edab34931 100644 --- a/agents/hermes/image-build-probes.py +++ b/agents/hermes/image-build-probes.py @@ -5,13 +5,80 @@ from __future__ import annotations +import os +import re +import subprocess import sys -from collections.abc import Callable +from collections.abc import Callable, Mapping, Sequence from pathlib import Path sys.path.insert(0, "/usr/local/lib/nemoclaw") +def _run_required_build_command( + label: str, argv: Sequence[str], *, env: Mapping[str, str] +) -> None: + result = subprocess.run(argv, env=env, check=False) + if result.returncode != 0: + raise RuntimeError(f"{label} exited with status {result.returncode}") + + +def prepare_generated_config( + *, + hermes: Path = Path("/usr/local/bin/hermes"), + node: Path = Path("/usr/local/bin/node"), + generator: Path = Path("/opt/nemoclaw-hermes-config/generate-config.ts"), + hermes_home: Path = Path("/sandbox/.hermes"), + env: Mapping[str, str] | None = None, +) -> None: + """Run upstream repair before NemoClaw replaces its generated config.""" + child_env = dict(os.environ if env is None else env) + child_env["HERMES_HOME"] = str(hermes_home) + _run_required_build_command( + "Hermes doctor", [str(hermes), "doctor", "--fix"], env=child_env + ) + _run_required_build_command( + "Hermes config generator", + [str(node), "--experimental-strip-types", str(generator)], + env=child_env, + ) + + +def verify_compatibility_retirement( + *, + hermes: Path = Path("/usr/local/bin/hermes"), + adapter: Path = Path("/usr/local/share/nemoclaw/hermes-cli-adapter-v1.json"), + oneshot: Path = Path("/opt/hermes/hermes_cli/oneshot.py"), +) -> None: + """Reject an upgrade that retains Hermes 0.20.6 compatibility behavior.""" + result = subprocess.run( + [str(hermes), "--version"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"Hermes version probe exited with status {result.returncode}") + version_output = result.stdout.strip() + match = re.search( + r"(?:^|[^0-9])v?([0-9]+\.[0-9]+\.[0-9]+)(?:$|[^0-9])", + version_output, + ) + if match is None: + raise RuntimeError(f"could not parse Hermes semver from: {version_output}") + semver = match.group(1) + if semver != "0.20.6" and '"resumed_oneshot"' in adapter.read_text(encoding="utf-8"): + raise RuntimeError( + f"installed Hermes {semver} but Hermes v0.20.6 compatibility workarounds " + "are still installed; re-review the workaround set before upgrading Hermes" + ) + if ( + "process_registry.wait_for_pending_completions(oneshot_task_id)" + not in oneshot.read_text(encoding="utf-8") + ): + raise RuntimeError("Hermes one-shot completion wait is not scoped to the exact turn") + + def _verify_profile_config_policy(config: dict, expected: dict[str, object]) -> None: from managed_policy import policy_value @@ -146,20 +213,7 @@ def reject_network(*_args, **_kwargs): finally: socket.socket.connect = original_connect socket.create_connection = original_create_connection - bundled_plugins = { - manifest.parent.name - for manifest in Path("/opt/hermes/plugins/platforms").glob("*/plugin.yaml") - } - built_in_optional = { - platform.value - for platform in Platform - if platform.value not in {"api_server", "local"} - } - expected = bundled_plugins | built_in_optional - assert "google_chat" in expected, expected - assert "whatsapp_cloud" in expected, expected - - for name in expected: + for name in ("a2a", "buzz", "google_chat", "whatsapp_cloud"): platform = Platform(name) platform_config = config.platforms.get(platform) assert platform_config is not None, name @@ -170,28 +224,78 @@ def reject_network(*_args, **_kwargs): def verify_cron_runtime_source() -> None: - from cron.executions import EXECUTIONS_FILE + from cron.executions import EXECUTIONS_FILE, _connect from hermes_cli.backup import _QUICK_STATE_FILES from hermes_constants import get_hermes_home expected = get_hermes_home().resolve() / "runtime" / "cron-executions.db" - assert EXECUTIONS_FILE == expected + assert EXECUTIONS_FILE is None + connection = _connect() + try: + databases = connection.execute("PRAGMA database_list").fetchall() + finally: + connection.close() + assert databases == [(0, "main", str(expected))], databases assert "runtime/cron-executions.db" in _QUICK_STATE_FILES assert "cron/executions.db" not in _QUICK_STATE_FILES def verify_session_preview() -> None: + import contextlib + import io + from types import SimpleNamespace + + from hermes_cli.sessions_cmd import cmd_sessions from hermes_state import SessionDB db = SessionDB() session_id = "nemoclaw-preview-smoke" - db.create_session(session_id, "cli") + db.create_session(session_id, "cli", cwd="/sandbox") + assert db.set_auto_title( + session_id, + "NEMOCLAW_PREVIEW_FIRST", + source=SessionDB.TITLE_SOURCE_DERIVED, + ) db.append_message(session_id, "user", "NEMOCLAW_PREVIEW_FIRST") db.append_message(session_id, "assistant", "ack") db.append_message(session_id, "user", "NEMOCLAW_PREVIEW_LATEST") rows = db.list_sessions_rich(limit=1) assert rows and rows[0]["id"] == session_id, rows assert rows[0]["preview"] == "NEMOCLAW_PREVIEW_LATEST", rows + db.close() + + output = io.StringIO() + with contextlib.redirect_stdout(output): + result = cmd_sessions( + SimpleNamespace( + sessions_action="list", + source=None, + limit=1, + workspace=None, + ) + ) + rendered = output.getvalue() + assert result is None, result + assert "NEMOCLAW_PREVIEW_LATEST" in rendered, rendered + assert "NEMOCLAW_PREVIEW_FIRST" not in rendered, rendered + + db = SessionDB() + assert db.set_session_title(session_id, "NEMOCLAW_USER_TITLE") + db.close() + output = io.StringIO() + with contextlib.redirect_stdout(output): + result = cmd_sessions( + SimpleNamespace( + sessions_action="list", + source=None, + limit=1, + workspace=None, + ) + ) + rendered = output.getvalue() + assert result is None, result + assert "NEMOCLAW_USER_TITLE" in rendered, rendered + assert "NEMOCLAW_PREVIEW_LATEST" not in rendered, rendered def verify_session_delete() -> None: @@ -390,6 +494,35 @@ def verify_cron_create() -> None: assert created["status"] == "claimed" +def verify_secure_directory_modes() -> None: + import stat + + from hermes_cli.config import _secure_dir + + assert os.environ.get("HERMES_SKIP_CHMOD") == "1" + expected_modes = { + Path("/sandbox/.hermes"): 0o3770, + Path("/sandbox/.hermes/runtime"): 0o2770, + } + for path, expected_mode in expected_modes.items(): + before = path.stat() + assert stat.S_IMODE(before.st_mode) == expected_mode, ( + path, + oct(before.st_mode), + ) + _secure_dir(path) + after = path.stat() + assert (after.st_uid, after.st_gid) == (before.st_uid, before.st_gid), ( + path, + before, + after, + ) + assert stat.S_IMODE(after.st_mode) == expected_mode, ( + path, + oct(after.st_mode), + ) + + def verify_cron_backup() -> None: import os import sqlite3 @@ -524,7 +657,26 @@ def verify_googlechat_override_seams() -> None: ) +def verify_managed_runtime_capability() -> None: + """Require the packaged ACP adapter and lazy MCP HTTP client surfaces.""" + import importlib.metadata as metadata + + import acp + import mcp + from acp_adapter.server import HermesACPAgent + from tools import mcp_tool + + _ = (acp, mcp, HermesACPAgent) + if metadata.version("agent-client-protocol") != "0.9.0": + raise RuntimeError("Hermes ACP SDK version is unavailable") + if not mcp_tool._ensure_mcp_sdk() or not getattr(mcp_tool, "_MCP_AVAILABLE", False): + raise RuntimeError("Hermes MCP client runtime is unavailable") + if not getattr(mcp_tool, "_MCP_HTTP_AVAILABLE", False): + raise RuntimeError("Hermes MCP Streamable HTTP runtime is unavailable") + + COMMANDS: dict[str, Callable[[], None]] = { + "compatibility-retirement": verify_compatibility_retirement, "cron-backup": verify_cron_backup, "cron-create": verify_cron_create, "cron-reopen": verify_cron_reopen, @@ -537,12 +689,15 @@ def verify_googlechat_override_seams() -> None: "googlechat-override-seams": verify_googlechat_override_seams, "gateway-runtime-metadata": verify_gateway_runtime_metadata, "langfuse-credentials": verify_langfuse_credentials, + "managed-runtime-capability": verify_managed_runtime_capability, "neutral-platform-inertness": verify_neutral_platform_inertness, "profile-policy": verify_profile_policy, + "prepare-generated-config": prepare_generated_config, "session-delete": verify_session_delete, "session-preview": verify_session_preview, "session-state-create": verify_session_state_create, "session-state-reopen": verify_session_state_reopen, + "secure-directory-modes": verify_secure_directory_modes, } diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index 7c96e07c38e..96e27b25481 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -16,9 +16,9 @@ homepage: "https://github.com/NousResearch/hermes-agent" install_method: curl # curl install.sh | bash binary_path: /usr/local/bin/hermes version_command: "hermes --version" -expected_version: "0.19.0" -# Hermes reports semver from `hermes --version` (for example, `0.19.0`) even -# though its GitHub release cadence is calendar-based (`v2026.7.20`). Declaring the +expected_version: "0.20.6" +# Hermes reports semver from `hermes --version` (for example, `0.20.6`) even +# though its GitHub release cadence is calendar-based (`v2026.8.27`). Declaring the # scheme keeps the staleness check off the shape heuristic when a manifest # could otherwise straddle both schemes (#6049). version_scheme: semver diff --git a/agents/hermes/patch-cron-execution-runtime.py b/agents/hermes/patch-cron-execution-runtime.py index 436ecc9c907..ba150e6b8d8 100755 --- a/agents/hermes/patch-cron-execution-runtime.py +++ b/agents/hermes/patch-cron-execution-runtime.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 """Relocate the pinned Hermes cron execution ledger into writable runtime state. -Hermes v2026.7.20 / 0.19.0 creates ``cron/executions.db`` from the gateway. +Hermes v2026.8.27 / 0.20.6 creates ``cron/executions.db`` from the gateway. NemoClaw keeps ``cron`` separate from the writable execution ledger because it is a high-risk location for cron job definitions, so a managed gateway restart cannot reopen that database. @@ -24,10 +24,10 @@ from pathlib import Path OLD_EXECUTIONS_PATH = ( - 'EXECUTIONS_FILE = get_hermes_home().resolve() / "cron" / "executions.db"' + ' path = EXECUTIONS_FILE or (get_hermes_home().resolve() / "cron" / "executions.db")' ) NEW_EXECUTIONS_PATH = ( - 'EXECUTIONS_FILE = get_hermes_home().resolve() / "runtime" / "cron-executions.db"' + ' path = EXECUTIONS_FILE or (get_hermes_home().resolve() / "runtime" / "cron-executions.db")' ) EXECUTIONS_CONTEXT = "from hermes_constants import get_hermes_home" diff --git a/agents/hermes/patch-cron-restore-drain.py b/agents/hermes/patch-cron-restore-drain.py index 08f228f2a13..aff99ff99fc 100755 --- a/agents/hermes/patch-cron-restore-drain.py +++ b/agents/hermes/patch-cron-restore-drain.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 """Compose NemoClaw's rebuild drain with pinned Hermes operator drain control. -Hermes v2026.7.20 / 0.19.0 scopes its operator marker to one container epoch. +Hermes v2026.8.27 / 0.20.6 scopes its operator marker to one container epoch. That is correct for operator lifecycle actions, but a NemoClaw rebuild marker must survive replacement gateway and container restarts until restored scripts and cron jobs are revalidated. Keep those two owners on separate paths and OR @@ -12,6 +12,10 @@ GatewayRunner also hydrates the composed state during construction. The async watcher still reconciles state transitions, but cron and new-turn gates cannot observe a false value during the watcher's first-tick window after restart. +When the root-owned release-recovery record is present, the root-owned +controller re-arms only scheduled, unclaimed one-shots that became due at or +after gate acquisition while dispatch was held. The gate remains active if +that durable jobs update fails. Exact source-shape checks fail closed when the pinned Hermes implementation changes. Remove this patch when upstream provides an equivalent independently owned, restart-stable maintenance drain. @@ -22,18 +26,11 @@ import argparse from pathlib import Path -OLD_MARKER_ANCHOR = '''_DRAIN_REQUEST_FILENAME = ".drain_request.json" - - -@functools.lru_cache(maxsize=1) -''' +OLD_MARKER_ANCHOR = '_DRAIN_REQUEST_FILENAME = ".drain_request.json"' NEW_MARKER_ANCHOR = '''_DRAIN_REQUEST_FILENAME = ".drain_request.json" _NEMOCLAW_CRON_RESTORE_DRAIN_PATH = Path( "/sandbox/.nemoclaw/hermes-cron-restore-drain.json" ) - - -@functools.lru_cache(maxsize=1) ''' OLD_OPERATOR_HEADER = '''def drain_requested(*, home: Optional[Path] = None) -> bool: @@ -130,6 +127,75 @@ def drain_requested(*, home: Optional[Path] = None) -> bool: return ''' +JOBS_ANCHOR = '''def get_due_jobs() -> List[Dict[str, Any]]: +''' +JOBS_RELEASE_HELPER = '''def rearm_nemoclaw_drained_oneshots(not_before: datetime, profile_homes) -> int: + """Re-arm one-shots held overdue by NemoClaw's restore drain. + + The root-owned controller calls this helper while the external drain still + blocks dispatch and passes the authenticated marker creation time. It + changes only enabled scheduled one-shots that have never run, carry no + dispatch or fire claim, and became due at or after that marker was acquired. + Each validated profile uses its own cron-store context, jobs lock, and + normal save path so profile isolation and ownership remain intact. + """ + now = _hermes_now() + not_before = _ensure_aware(not_before) + if not_before > now: + raise RuntimeError("NemoClaw cron restore drain time is in the future") + rearm_gate = not_before.isoformat() + replacement = now + timedelta(seconds=2) + replacement_schedule = parse_schedule(replacement.isoformat()) + replacement_next = compute_next_run(replacement_schedule) + if replacement_next is None: + raise RuntimeError("NemoClaw cron restore could not schedule delayed one-shots") + + changed = 0 + for profile_home in profile_homes: + profile_changed = 0 + with use_cron_store(profile_home): + with _jobs_lock(): + jobs = load_jobs() + for job in jobs: + schedule = job.get("schedule") + repeat = job.get("repeat") + if ( + not isinstance(schedule, dict) + or schedule.get("kind") != "once" + or job.get("enabled", True) is not True + or job.get("state") not in {None, "scheduled"} + or job.get("last_run_at") is not None + or job.get("run_claim") is not None + or job.get("fire_claim") is not None + or (isinstance(repeat, dict) and repeat.get("completed", 0) != 0) + ): + continue + run_at = schedule.get("run_at") + next_run_at = job.get("next_run_at") + if not isinstance(run_at, str) or not isinstance(next_run_at, str): + continue + try: + scheduled = _ensure_aware(datetime.fromisoformat(run_at)) + next_run = _ensure_aware(datetime.fromisoformat(next_run_at)) + except (TypeError, ValueError): + continue + if scheduled < not_before or next_run < not_before: + continue + if scheduled > now or next_run > now: + continue + job["schedule"] = dict(replacement_schedule) + job["schedule_display"] = replacement_schedule.get("display") + job["next_run_at"] = replacement_next + job["nemoclaw_restore_rearm_gate"] = rearm_gate + profile_changed += 1 + if profile_changed: + save_jobs(jobs) + changed += profile_changed + return changed + + +''' + DRAIN_CONTEXT = "from utils import atomic_json_write" RUN_CONTEXT = ( "class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, " @@ -165,9 +231,14 @@ def _state( ) -def patch_files(drain_control_path: Path, gateway_run_path: Path) -> None: +def patch_files( + drain_control_path: Path, + gateway_run_path: Path, + cron_jobs_path: Path, +) -> None: drain_source = drain_control_path.read_text(encoding="utf-8") run_source = gateway_run_path.read_text(encoding="utf-8") + jobs_source = cron_jobs_path.read_text(encoding="utf-8") _require_exact(drain_source, DRAIN_CONTEXT, "drain-control import context") _require_exact( @@ -176,6 +247,7 @@ def patch_files(drain_control_path: Path, gateway_run_path: Path) -> None: "drain notification predicate", ) _require_exact(run_source, RUN_CONTEXT, "GatewayRunner declaration") + _require_exact(jobs_source, JOBS_ANCHOR, "cron due-jobs boundary") drain_state = _state( drain_source, old_shapes=(OLD_MARKER_ANCHOR, OLD_OPERATOR_HEADER), @@ -192,7 +264,17 @@ def patch_files(drain_control_path: Path, gateway_run_path: Path) -> None: new_shapes=(NEW_RUN_BLOCK, NEW_ENTER_BLOCK), description="GatewayRunner initialization", ) - if drain_state != run_state: + helper_count = jobs_source.count(JOBS_RELEASE_HELPER) + if helper_count == 0: + jobs_state = "unpatched" + elif helper_count == 1: + jobs_state = "patched" + else: + raise SystemExit( + "ERROR: Hermes cron restore drain source shape changed; " + "cron release helper is duplicated" + ) + if len({drain_state, run_state, jobs_state}) != 1: raise SystemExit( "ERROR: Hermes cron restore drain patch is only partially applied" ) @@ -207,8 +289,10 @@ def patch_files(drain_control_path: Path, gateway_run_path: Path) -> None: ) run_source = run_source.replace(OLD_RUN_BLOCK, NEW_RUN_BLOCK) run_source = run_source.replace(OLD_ENTER_BLOCK, NEW_ENTER_BLOCK) + jobs_source = jobs_source.replace(JOBS_ANCHOR, f"{JOBS_RELEASE_HELPER}{JOBS_ANCHOR}") drain_control_path.write_text(drain_source, encoding="utf-8") gateway_run_path.write_text(run_source, encoding="utf-8") + cron_jobs_path.write_text(jobs_source, encoding="utf-8") def main() -> int: @@ -223,8 +307,17 @@ def main() -> int: default="/opt/hermes/gateway/run.py", help="Hermes gateway runner module to patch", ) + parser.add_argument( + "--cron-jobs", + default="/opt/hermes/cron/jobs.py", + help="Hermes cron jobs module to patch", + ) args = parser.parse_args() - patch_files(Path(args.drain_control), Path(args.gateway_run)) + patch_files( + Path(args.drain_control), + Path(args.gateway_run), + Path(args.cron_jobs), + ) return 0 diff --git a/agents/hermes/patch-discord-recovery-permissions.py b/agents/hermes/patch-discord-recovery-permissions.py index c3406c1c60d..93f3de54b39 100755 --- a/agents/hermes/patch-discord-recovery-permissions.py +++ b/agents/hermes/patch-discord-recovery-permissions.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 """Patch the pinned Hermes Discord recovery ledger to remain group-writable. -Hermes v2026.7.20 / 0.19.0 creates the Discord recovery database as the +Hermes v2026.8.27 / 0.20.6 creates the Discord recovery database as the ``gateway`` user and then forces mode 0600. NemoClaw snapshot and restore commands run as the ``sandbox`` user, so that mode prevents online backup and prevents the gateway from reopening a sandbox-restored database. diff --git a/agents/hermes/patch-gateway-runtime-metadata.py b/agents/hermes/patch-gateway-runtime-metadata.py index 12507d3dfb3..95a07769d4b 100755 --- a/agents/hermes/patch-gateway-runtime-metadata.py +++ b/agents/hermes/patch-gateway-runtime-metadata.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 """Relocate pinned Hermes gateway metadata below its writable runtime directory. -Hermes v0.19.0 stores ``gateway.pid``, ``gateway.lock``, and +Hermes v0.20.6 stores ``gateway.pid``, ``gateway.lock``, and ``gateway_state.json`` directly below ``HERMES_HOME``. NemoClaw correctly makes that config root root-owned and non-writable, so NemoClaw's managed stop/start recovery cannot remove the old PID file or atomically diff --git a/agents/hermes/patch-hermes-sqlite-temp-store.py b/agents/hermes/patch-hermes-sqlite-temp-store.py index 683f0ca7e47..a89980376ea 100755 --- a/agents/hermes/patch-hermes-sqlite-temp-store.py +++ b/agents/hermes/patch-hermes-sqlite-temp-store.py @@ -4,7 +4,7 @@ """Patch SessionDB for SQLite temp storage and NemoClaw's shared state ledger. Source-of-truth note for this localized Hermes runtime patch: - - Invalid state: Hermes v0.19.0 SessionDB does not set PRAGMA temp_store=MEMORY, + - Invalid state: Hermes v0.20.6 SessionDB does not set PRAGMA temp_store=MEMORY, so SQLite falls back to file-based temp storage when processing FK constraints (for example, the ON DELETE CASCADE on session_model_usage -> sessions). When `hermes sessions delete` is invoked through OpenShell sandbox execution — @@ -39,7 +39,7 @@ SessionDB as gateway then sandbox and require exact state.db metadata plus a persisted cross-identity append. They require exact WAL/SHM metadata when SQLite retains WAL mode and require those sidecars absent when Hermes' - `apply_wal_with_fallback` selects DELETE mode on a WAL-incompatible filesystem. + selected journal mode is DELETE on a WAL-incompatible filesystem. - Removal condition: delete this patch when the pinned Hermes runtime natively sets `PRAGMA temp_store=MEMORY` (or equivalent) in `SessionDB.__init__`. """ @@ -49,21 +49,16 @@ import argparse from pathlib import Path -IMPORTS_OLD = """import logging -import random -import re +IMPORTS_OLD = """import re import sqlite3 import sys""" -IMPORTS_NEW = """import logging -import os -import random -import re +IMPORTS_NEW = """import re import sqlite3 import stat import sys""" HELPER_ANCHOR_OLD = """DEFAULT_DB_PATH = get_hermes_home() / "state.db" -SCHEMA_VERSION = 22""" +# How long SessionDB stops attempting read-only opens""" HELPER = '''_NEMOCLAW_SHARED_STATE_LINK = Path("/sandbox/.hermes/state.db") _NEMOCLAW_SHARED_STATE_DIRECTORY = Path("/sandbox/.hermes/runtime") _NEMOCLAW_SHARED_STATE_NAMES = ("state.db", "state.db-wal", "state.db-shm") @@ -151,21 +146,21 @@ def _nemoclaw_normalize_shared_state_permissions(db_path: Path) -> None: {HELPER} -SCHEMA_VERSION = 22''' +# How long SessionDB stops attempting read-only opens''' CONNECT_ANCHOR_OLD = """ def _connect_and_init(): - self._conn = sqlite3.connect(""" + self._conn = _connect_tracked_db(""" CONNECT_ANCHOR_NEW = """ def _connect_and_init(): _nemoclaw_normalize_shared_state_permissions(self.db_path) - self._conn = sqlite3.connect(""" + self._conn = _connect_tracked_db(""" INIT_ANCHOR_OLD = """ self._init_schema()""" INIT_ANCHOR_NEW = """ self._init_schema() _nemoclaw_normalize_shared_state_permissions(self.db_path)""" CONNECTION_OLD = ( - 'apply_wal_with_fallback(self._conn, db_label="state.db")\n' + 'apply_database_pragmas(self._conn, db_label="state.db")\n' ' self._conn.execute("PRAGMA foreign_keys=ON")' ) CONNECTION_TEMP_ONLY = ( - 'apply_wal_with_fallback(self._conn, db_label="state.db")\n' + 'apply_database_pragmas(self._conn, db_label="state.db")\n' ' self._conn.execute("PRAGMA temp_store=MEMORY")\n' ' self._conn.execute("PRAGMA foreign_keys=ON")' ) diff --git a/agents/hermes/patch-langfuse-credentials.mts b/agents/hermes/patch-langfuse-credentials.mts index a8ee6aeff0a..fa1a86a825e 100644 --- a/agents/hermes/patch-langfuse-credentials.mts +++ b/agents/hermes/patch-langfuse-credentials.mts @@ -7,7 +7,7 @@ import process from "node:process"; import { pathToFileURL } from "node:url"; /** - * Patch the Langfuse validator bundled with pinned Hermes v2026.7.20 / 0.19.0. + * Patch the Langfuse validator bundled with pinned Hermes v2026.8.27 / 0.20.6. * * Hermes rejects OpenShell resolver placeholders before the Langfuse SDK can * turn them into outbound authentication headers. NemoClaw keeps the real @@ -113,20 +113,20 @@ def _get_langfuse() -> Optional[Langfuse]: { name: "HTTPS base URL gate", old: `\ - base_url = _env("HERMES_LANGFUSE_BASE_URL") or _env("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com" - environment = _env("HERMES_LANGFUSE_ENV") or _env("LANGFUSE_ENV") + base_url = _env("HERMES_LANGFUSE_BASE_URL") or _env("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com" + environment = _env("HERMES_LANGFUSE_ENV") or _env("LANGFUSE_ENV") `, patched: `\ - base_url = _env("HERMES_LANGFUSE_BASE_URL") or _env("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com" - base_url_issue = _validate_langfuse_base_url(base_url) - if base_url_issue: - logger.warning( - "Langfuse plugin: invalid base URL, traces will NOT be emitted (%s).", - base_url_issue, - ) - _LANGFUSE_CLIENT = _INIT_FAILED - return None - environment = _env("HERMES_LANGFUSE_ENV") or _env("LANGFUSE_ENV") + base_url = _env("HERMES_LANGFUSE_BASE_URL") or _env("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com" + base_url_issue = _validate_langfuse_base_url(base_url) + if base_url_issue: + logger.warning( + "Langfuse plugin: invalid base URL, traces will NOT be emitted (%s).", + base_url_issue, + ) + _LANGFUSE_CLIENT = _INIT_FAILED + return None + environment = _env("HERMES_LANGFUSE_ENV") or _env("LANGFUSE_ENV") `, }, ] as const; diff --git a/agents/hermes/patch-neutral-platform-env-activation.py b/agents/hermes/patch-neutral-platform-env-activation.py index 6b6597f9f0c..7c3843e9076 100755 --- a/agents/hermes/patch-neutral-platform-env-activation.py +++ b/agents/hermes/patch-neutral-platform-env-activation.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 """Keep explicitly disabled Hermes platforms inert under ambient credentials. -Hermes v0.19.0 honors ``enabled: false`` in its shared and plugin-driven +Hermes v0.20.6 honors ``enabled: false`` in its shared and plugin-driven environment enablement paths, but several built-in adapters still assign ``enabled = True`` directly when credentials are present. A neutral managed image explicitly disables every packaged optional platform. Preserve those @@ -22,11 +22,13 @@ from pathlib import Path IMPORT_ANCHOR = """import logging +import math import os import json """ PATCHED_IMPORT_ANCHOR = """from copy import deepcopy import logging +import math import os import json """ diff --git a/agents/hermes/patch-profile-policy-defaults.py b/agents/hermes/patch-profile-policy-defaults.py index 42e311bbe1f..7bed85cbeef 100755 --- a/agents/hermes/patch-profile-policy-defaults.py +++ b/agents/hermes/patch-profile-policy-defaults.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Pin fail-safe defaults for every Hermes v0.19.0 profile home. +"""Pin fail-safe defaults for every Hermes v0.20.6 profile home. Fresh Hermes named profiles intentionally omit ``config.yaml``. The upstream -v2026.7.20 defaults would therefore enable smart command approval, browser +v2026.8.27 defaults would therefore enable smart command approval, browser evaluation of sensitive primitives, reasoning/commentary display, update-time state mutation, and indefinite gateway sessions outside NemoClaw's generated default home. @@ -15,7 +15,8 @@ ``DEFAULT_CONFIG``: * ``tools.browser_tool`` reads raw per-home YAML, so its missing-key and error - fallbacks must keep the sensitive-expression denylist enabled. + fallbacks must keep the sensitive-expression denylist enabled. Its runtime + npx fallback must also remain offline after all ambient values are copied. * ``gateway.config.SessionResetPolicy`` constructs its own defaults, so both its dataclass and ``from_dict`` fallback must retain the prior 24-hour/daily reset policy. @@ -26,9 +27,10 @@ * ``hermes_cli.main`` independently defaults update backups and CUA refresh on when configuration is missing or unreadable. -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. +Every input file is bound to its exact reviewed v2026.8.27 source state before +any edit. The browser source hash includes NemoClaw's preceding exact +``agent-browser`` dependency pin. 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 @@ -56,13 +58,14 @@ ) EXPECTED_SOURCE_SHA256 = { - "config": "172b78ecb923048859ca177d96f5b010b44ec74bb1d13553577ff49bde1a071d", - "browser": "02b4a0a0c8fc8b204c8f818dff1dd64295a817e5543b8a643198bcedbfbbcba2", - "gateway": "7221ee05798566ca7cf570035615a9b29034cf92ce5a6eaa5eec0693040c08aa", - "cli": "cbcf1780174a03b225508244575915225a36502f54ad4cddf1da644d9174fec4", - "tui": "5d00832327e4362ac75032f95003e1fa49aead4756cf7927dcfd66447b205a59", - "agent": "85b7cb13d6e6306e75d5eec46f193433df680425533b7d35ee99e0f7eab9512a", - "main": "d6bf89a33fb708376a7ab354cff8081a3c3726dbfb91d84bbb679cd667db596c", + "config": "3fa2c9f02a76d77602f9b09b7b01f72ca45a40eea92dbac33cc3a1fc5071bff8", + "browser": "b43608826bb10f9bf919ca97757bf36fc95247bd8b14fa8626a113c639cfd73e", + "gateway": "d88dcda8c5a14b79d84afcc1d5784c165858ab5d6f289ba59fe421502d2c63a3", + "cli": "85c95927002a77602b0fb0384413357b6ee0149dfc5b31e048c29d59654a22a9", + "tui": "6fdeca2133b22a88c527a63764eb201c24a27fc2e894045e9bdb647f89ea7d26", + "tui_config": "2ffe5fae39e8962a086d4eea7ec26c3f1d29f2bb8a97422d5606eecaa2b3f116", + "agent": "883168664a89bcf8954bbe486b672ab01c96fc0c06c88acdaf21559905a60276", + "main": "fb4ee75ebcf12bd9bc014d212c7abc110e1afbcf0c2cb79caa7230dd58006911", } CONFIG_REQUIRED_UNCHANGED = ('"allow_unsafe_evaluate": False',) @@ -160,6 +163,14 @@ def patch_browser_source(source: str, values: dict[str, object]) -> str: " # NemoClaw compatibility override: config errors fail restricted.\n" f" return {expected}", ), + ( + " env[_key] = os.environ[_key]\n" + " return env", + " env[_key] = os.environ[_key]\n" + " # NemoClaw compatibility override: runtime npx never uses the network.\n" + ' env["npm_config_offline"] = "true"\n' + " return env", + ), ) return _replace_exact(source, replacements, label="Hermes browser policy") @@ -192,34 +203,30 @@ def patch_cli_source(source: str, values: dict[str, object]) -> str: def patch_tui_source(source: str, values: dict[str, object]) -> str: expected = _literal(values["display.show_reasoning"]) - replacements = ( - ( + return _replace_exact( + source, + (( "# 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, - ), - ( + ),), + label="Hermes TUI policy", + ) + + +def patch_tui_config_source(source: str, values: dict[str, object]) -> str: + expected = _literal(values["display.show_reasoning"]) + return _replace_exact( + source, + (( '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, - ), + ),), + label="Hermes TUI config policy", ) - patched = source - 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: - raise ValueError( - f"Hermes TUI policy source shape changed for {old!r}: " - f"expected {expected_count} unpatched occurrences, found {old_count}; " - f"prepatched occurrences: {new_count}" - ) - patched = patched.replace(old, new) - return patched def patch_agent_source(source: str, values: dict[str, object]) -> str: @@ -285,7 +292,7 @@ def patch_file(path: Path, kind: str, values: dict[str, object]) -> None: expected_sha256 = EXPECTED_SOURCE_SHA256[kind] if actual_sha256 != expected_sha256: raise SystemExit( - f"ERROR: {path} is not the reviewed Hermes v2026.7.20 {kind} source; " + f"ERROR: {path} is not the reviewed Hermes v2026.8.27 {kind} source; " f"expected sha256 {expected_sha256}, got {actual_sha256}" ) @@ -295,6 +302,7 @@ def patch_file(path: Path, kind: str, values: dict[str, object]) -> None: "gateway": patch_gateway_source, "cli": patch_cli_source, "tui": patch_tui_source, + "tui_config": patch_tui_config_source, "agent": patch_agent_source, "main": patch_main_source, }[kind] @@ -315,7 +323,7 @@ def main() -> int: ) parser.add_argument( "--config", - default="/opt/hermes/hermes_cli/config.py", + default="/opt/hermes/hermes_cli/config_defaults.py", help="Pinned Hermes configuration module", ) parser.add_argument( @@ -343,9 +351,14 @@ def main() -> int: default="/opt/hermes/agent/agent_init.py", help="Pinned Hermes agent initialization module", ) + parser.add_argument( + "--tui-config", + default="/opt/hermes/tui_gateway/methods_config.py", + help="Pinned Hermes TUI configuration methods module", + ) parser.add_argument( "--main", - default="/opt/hermes/hermes_cli/main.py", + default="/opt/hermes/hermes_cli/update_cmd.py", help="Pinned Hermes main/update module", ) args = parser.parse_args() @@ -359,6 +372,7 @@ def main() -> int: 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.tui_config), "tui_config", values) patch_file(Path(args.agent), "agent", values) patch_file(Path(args.main), "main", values) return 0 diff --git a/agents/hermes/patch-session-list-preview.py b/agents/hermes/patch-session-list-preview.py index 06bdafe3ff5..40e7ae1f268 100755 --- a/agents/hermes/patch-session-list-preview.py +++ b/agents/hermes/patch-session-list-preview.py @@ -1,23 +1,26 @@ #!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Patch pinned Hermes v0.19.0 session-list previews to show the latest user turn. +"""Patch pinned Hermes v0.20.6 session lists to show the latest user turn. Source-of-truth note for this localized Hermes runtime patch: - - Invalid state: Hermes v0.19.0 computes `sessions list` preview text from - the first user message, but #5254's resumed/continued one-shot UX expects - the original row to reflect the latest appended turn. - - Value being patched: pinned/prebuilt `/opt/hermes/hermes_state.py` + - Invalid state: Hermes v0.20.6 computes `sessions list` preview text from + the first user message. Its workspace-aware titled table then hides the + preview behind an automatic seed title, so #5254's resumed/continued + one-shot UX keeps displaying the seed turn instead of the latest appended + turn. User-authored titles remain authoritative. + - Values being patched: pinned/prebuilt `/opt/hermes/hermes_state.py` occurrences of `ORDER BY m.timestamp, m.id LIMIT 1` inside - `SessionDB.list_sessions_rich()`. + `SessionDB.list_sessions_rich()`, plus the workspace-aware title cell in + `/opt/hermes/hermes_cli/sessions_cmd.py`. - Source-fix constraint: NemoClaw layers a sandbox image on top of the published Hermes runtime; the source fix belongs upstream in Hermes, not in NemoClaw's TypeScript or wrapper code. - Regression test: this script's exact occurrence count fails closed when the - pinned source shape drifts, the Dockerfile greps for the patched query - pattern after patching, and the Dockerfile smoke test creates a - `SessionDB`, appends first/latest user turns, and asserts the list preview - returns `NEMOCLAW_PREVIEW_LATEST`. + pinned source shape drifts. The Dockerfile smoke test creates a titled, + workspace-bound `SessionDB` row, appends first/latest user turns, and + asserts both the list preview and rendered table use + `NEMOCLAW_PREVIEW_LATEST`. - Removal condition: delete this patch when the pinned Hermes runtime natively uses the latest user turn for `sessions list` previews. """ @@ -27,36 +30,71 @@ import argparse from pathlib import Path -OLD = "ORDER BY m.timestamp, m.id LIMIT 1" -NEW = "ORDER BY m.timestamp DESC, m.id DESC LIMIT 1" -EXPECTED_OCCURRENCES = 6 +STATE_OLD = "ORDER BY m.timestamp, m.id LIMIT 1" +STATE_NEW = "ORDER BY m.timestamp DESC, m.id DESC LIMIT 1" +STATE_EXPECTED_OCCURRENCES = 5 +COMMAND_OLD = ''' if has_titles: + title = (s.get("title") or "—")[:26] + print(f"{title:<28} {ws:<18} {last_active:<13} {s['id']}")''' +COMMAND_NEW = ''' if has_titles: + title = ( + s.get("preview") + if s.get("title_source") in ("derived", "llm") + else s.get("title") + ) or s.get("preview") or "—" + title = title[:26] + print(f"{title:<28} {ws:<18} {last_active:<13} {s['id']}")''' -def patch_file(path: Path) -> None: +def patched_source(path: Path, old: str, new: str, expected: int, label: str) -> str: source = path.read_text(encoding="utf-8") - old_count = source.count(OLD) - new_count = source.count(NEW) - if old_count == 0 and new_count == EXPECTED_OCCURRENCES: - return - if old_count != EXPECTED_OCCURRENCES: + old_count = source.count(old) + new_count = source.count(new) + if old_count == 0 and new_count == expected: + return source + if old_count != expected or new_count != 0: raise SystemExit( - "ERROR: Hermes session preview query shape changed; " - f"expected {EXPECTED_OCCURRENCES} unpatched occurrences, found {old_count} " + f"ERROR: Hermes session {label} shape changed; " + f"expected {expected} unpatched occurrences, found {old_count} " f"(already patched occurrences: {new_count})" ) - path.write_text(source.replace(OLD, NEW), encoding="utf-8") + return source.replace(old, new) + + +def patch_files(state_path: Path, command_path: Path) -> None: + state_source = patched_source( + state_path, + STATE_OLD, + STATE_NEW, + STATE_EXPECTED_OCCURRENCES, + "preview query", + ) + command_source = patched_source( + command_path, + COMMAND_OLD, + COMMAND_NEW, + 1, + "list renderer", + ) + state_path.write_text(state_source, encoding="utf-8") + command_path.write_text(command_source, encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser() parser.add_argument( - "path", + "state_path", nargs="?", default="/opt/hermes/hermes_state.py", help="Hermes state module to patch", ) + parser.add_argument( + "--sessions-command-path", + default="/opt/hermes/hermes_cli/sessions_cmd.py", + help="Hermes sessions command module to patch", + ) args = parser.parse_args() - patch_file(Path(args.path)) + patch_files(Path(args.state_path), Path(args.sessions_command_path)) return 0 diff --git a/agents/hermes/plugin/__init__.py b/agents/hermes/plugin/__init__.py index f475bcf1c4d..dc2c24739dd 100644 --- a/agents/hermes/plugin/__init__.py +++ b/agents/hermes/plugin/__init__.py @@ -158,7 +158,6 @@ def _get_env_value(key, default=None): env_paths.append(os.path.join(hermes_home, ".env")) env_paths.extend( [ - "/sandbox/.hermes-data/.env", "/sandbox/.hermes/.env", os.path.expanduser("~/.hermes/.env"), ], @@ -187,9 +186,7 @@ def _load_hermes_dotenv(): try: from hermes_cli.env_loader import load_hermes_dotenv - hermes_home = os.getenv("HERMES_HOME") - if not hermes_home and os.path.isdir("/sandbox/.hermes-data"): - hermes_home = "/sandbox/.hermes-data" + hermes_home = os.getenv("HERMES_HOME") or "/sandbox/.hermes" load_hermes_dotenv(hermes_home=hermes_home) except Exception: # Runtime env still works when Hermes' optional dotenv loader is absent. @@ -1253,7 +1250,7 @@ def _build_nemoclaw_agent_context(platform=None): hermes_home = ( os.getenv("HERMES_HOME") or _get_env_value("HERMES_HOME", "") - or "/sandbox/.hermes-data" + or "/sandbox/.hermes" ) services = _active_managed_gateway_services() service_text = ", ".join(services) if services else "none detected" @@ -1282,7 +1279,7 @@ def _build_nemoclaw_agent_context(platform=None): child_tool_line = ( "- Some tools, especially managed code/terminal tools, execute in child " + "tool sandboxes such as Modal. Seeing /__modal, MODAL_SANDBOX_ID, a " - + "missing hermes binary, or missing ~/.hermes-data inside a tool shell " + + "missing hermes binary, or missing ~/.hermes inside a tool shell " + "means that shell is a child tool sandbox, not proof that Hermes is " + "running on the host." ) @@ -1461,7 +1458,10 @@ def _install_googlechat_adapter(ctx): def register(ctx): """Register NemoClaw tools and hooks with Hermes.""" _install_nous_tool_broker_patch() - _install_messaging_response_patch() + # Hermes 0.20.6 discovers plugins on a background thread while run_agent + # imports model_tools and waits for discovery to finish. Importing + # run_agent from this registration path deadlocks those two threads. The + # pre_llm_call hook below installs the patch before response processing. _install_googlechat_adapter(ctx) # Register status tool diff --git a/agents/hermes/runtime-boundaries.patch b/agents/hermes/runtime-boundaries.patch index 5b3186615c6..333fb6a543f 100644 --- a/agents/hermes/runtime-boundaries.patch +++ b/agents/hermes/runtime-boundaries.patch @@ -1,13 +1,13 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Hardens the pinned Hermes gateway boundary against sandbox-controlled plugin -# discovery, mutable dotenv process controls, and package-installer inputs. +# Hardens the pinned Hermes v2026.8.27 gateway boundary against sandbox-controlled +# plugin discovery, mutable dotenv process controls, and package-installer inputs. diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py -index 9dea6e5fe9..e5e75594a9 100644 +index f926c6e..82b0219 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py -@@ -160,10 +160,29 @@ def _sanitize_loaded_credentials() -> None: +@@ -340,18 +340,37 @@ def _sanitize_loaded_credentials() -> None: def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None: @@ -24,13 +24,29 @@ index 9dea6e5fe9..e5e75594a9 100644 + if nemoclaw_protected_process_control(key) + } try: -- load_dotenv(dotenv_path=path, override=override, encoding="utf-8") +- # utf-8-sig strips a leading UTF-8 BOM if present (PowerShell 5.1 +- # Set-Content -Encoding UTF8 / Notepad) and is a no-op for BOM-less +- # UTF-8. Plain "utf-8" would keep U+FEFF on the first key name and +- # silently drop it from os.environ under its canonical name. +- load_dotenv(dotenv_path=path, override=override, encoding="utf-8-sig") - except UnicodeDecodeError: -- load_dotenv(dotenv_path=path, override=override, encoding="latin-1") +- # utf-8-sig can't strip a BOM once we fall back to latin-1 decode. +- raw = path.read_bytes() +- if raw.startswith(codecs.BOM_UTF8): +- raw = raw[len(codecs.BOM_UTF8) :] +- load_dotenv(stream=io.StringIO(raw.decode("latin-1")), override=override) + try: -+ load_dotenv(dotenv_path=path, override=override, encoding="utf-8") ++ # utf-8-sig strips a leading UTF-8 BOM if present (PowerShell 5.1 ++ # Set-Content -Encoding UTF8 / Notepad) and is a no-op for BOM-less ++ # UTF-8. Plain "utf-8" would keep U+FEFF on the first key name and ++ # silently drop it from os.environ under its canonical name. ++ load_dotenv(dotenv_path=path, override=override, encoding="utf-8-sig") + except UnicodeDecodeError: -+ load_dotenv(dotenv_path=path, override=override, encoding="latin-1") ++ # utf-8-sig can't strip a BOM once we fall back to latin-1 decode. ++ raw = path.read_bytes() ++ if raw.startswith(codecs.BOM_UTF8): ++ raw = raw[len(codecs.BOM_UTF8) :] ++ load_dotenv(stream=io.StringIO(raw.decode("latin-1")), override=override) + finally: + if protected is not None: + for key in tuple(os.environ): @@ -41,19 +57,18 @@ index 9dea6e5fe9..e5e75594a9 100644 # loaded. API keys must be pure ASCII since they're sent as HTTP # header values (httpx encodes headers as ASCII). Non-ASCII chars diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py -index 6ca393fca5..f798e33ec7 100644 +index 0d6dc6a..c7422bc 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py -@@ -46,7 +46,7 @@ from dataclasses import dataclass, field - from pathlib import Path - from typing import Any, Callable, Dict, List, Optional, Set, Union - --from hermes_constants import get_hermes_home -+from hermes_constants import get_hermes_home, nemoclaw_managed_gateway_plugins_only - from utils import env_var_enabled, fast_safe_load - from hermes_cli.config import cfg_get - from hermes_cli.middleware import OBSERVER_SCHEMA_VERSION, VALID_MIDDLEWARE -@@ -59,6 +59,8 @@ def get_bundled_plugins_dir() -> Path: +@@ -58,6 +58,7 @@ from typing import (Any, Callable, Dict, Iterable, List, Mapping, Optional, Set, + from hermes_constants import ( + get_hermes_home, + hermes_home_key, ++ nemoclaw_managed_gateway_plugins_only, + reset_hermes_home_override, + set_hermes_home_override, + ) +@@ -87,6 +88,8 @@ def get_bundled_plugins_dir() -> Path: installs) so read-only store paths are consulted first. Falls back to the in-repo path used during development. """ @@ -62,7 +77,21 @@ index 6ca393fca5..f798e33ec7 100644 env_override = os.getenv("HERMES_BUNDLED_PLUGINS") if env_override: return Path(env_override) -@@ -1346,29 +1348,36 @@ class PluginManager: +@@ -4200,9 +4203,10 @@ class PluginManager: + # Directory plugins are collected above. Pip / entry-point plugins + # are intentionally separate: portable packages are directory-only + # and the startup MCP probe must not import or register entry points. +- ep_manifests = self._scan_entry_points() +- logger.debug(" entrypoints: %d manifest(s)", len(ep_manifests)) +- manifests.extend(ep_manifests) ++ if not nemoclaw_managed_gateway_plugins_only(): ++ ep_manifests = self._scan_entry_points() ++ logger.debug(" entrypoints: %d manifest(s)", len(ep_manifests)) ++ manifests.extend(ep_manifests) + + # Load each manifest (skip user-disabled plugins). + # Later sources override earlier ones on key collision — user +@@ -4423,25 +4427,29 @@ class PluginManager: logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms)) manifests.extend(bundled_platforms) @@ -73,7 +102,8 @@ index 6ca393fca5..f798e33ec7 100644 - logger.debug(" user: %d manifest(s)", len(user_manifests)) - manifests.extend(user_manifests) - -- # 3. Project plugins (./.hermes/plugins/) +- # 3. Project plugins (./.hermes/plugins/), only when explicitly opted +- # in. This must match the full discovery gate exactly. - if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"): - project_dir = Path.cwd() / ".hermes" / "plugins" - logger.debug("Scanning project plugins: %s", project_dir) @@ -94,7 +124,8 @@ index 6ca393fca5..f798e33ec7 100644 + logger.debug(" user: %d manifest(s)", len(user_manifests)) + manifests.extend(user_manifests) + -+ # 3. Project plugins (./.hermes/plugins/) ++ # 3. Project plugins (./.hermes/plugins/), only when explicitly opted ++ # in. This must match the full discovery gate exactly. + if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"): + project_dir = Path.cwd() / ".hermes" / "plugins" + logger.debug("Scanning project plugins: %s", project_dir) @@ -106,25 +137,13 @@ index 6ca393fca5..f798e33ec7 100644 + "Project plugins disabled (set HERMES_ENABLE_PROJECT_PLUGINS=1 to enable)" + ) -- # 4. Pip / entry-point plugins -- ep_manifests = self._scan_entry_points() -- logger.debug(" entrypoints: %d manifest(s)", len(ep_manifests)) -- manifests.extend(ep_manifests) -+ # 4. Pip / entry-point plugins. The managed gateway admits only the -+ # image-owned bundled tree; same-identity Hermes retains upstream entry -+ # point discovery. -+ if not bundled_only: -+ ep_manifests = self._scan_entry_points() -+ logger.debug(" entrypoints: %d manifest(s)", len(ep_manifests)) -+ manifests.extend(ep_manifests) + return manifests - # Load each manifest (skip user-disabled plugins). - # Later sources override earlier ones on key collision — user diff --git a/hermes_constants.py b/hermes_constants.py -index 639d6d48f0..bd442e7ec2 100644 +index 34cc6f8..6f9c7a6 100644 --- a/hermes_constants.py +++ b/hermes_constants.py -@@ -18,6 +18,152 @@ _UNSET = object() +@@ -17,6 +17,152 @@ _UNSET = object() _HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar( "_HERMES_HOME_OVERRIDE", default=_UNSET ) @@ -275,10 +294,10 @@ index 639d6d48f0..bd442e7ec2 100644 + ) + return clean - - def set_hermes_home_override(path: str | Path | None) -> Token: + # ── TUI busy-indicator styles ───────────────────────────────────────── + # Single source of truth shared by the CLI /indicator command, the TUI diff --git a/plugins/cron_providers/__init__.py b/plugins/cron_providers/__init__.py -index 456c81b41e..abd1afb6ce 100644 +index 79dd1a2..f00b6d6 100644 --- a/plugins/cron_providers/__init__.py +++ b/plugins/cron_providers/__init__.py @@ -69,7 +69,9 @@ def _register_synthetic_package(name: str, search_locations: List[str]) -> None: @@ -293,10 +312,10 @@ index 456c81b41e..abd1afb6ce 100644 return d if d.is_dir() else None except Exception: diff --git a/plugins/memory/__init__.py b/plugins/memory/__init__.py -index cccda75ce8..f08e41aa37 100644 +index a24029a..92c44b7 100644 --- a/plugins/memory/__init__.py +++ b/plugins/memory/__init__.py -@@ -64,7 +64,9 @@ def _register_synthetic_package(name: str, search_locations: List[str]) -> None: +@@ -80,7 +80,9 @@ def _register_synthetic_package(name: str, search_locations: List[str]) -> None: def _get_user_plugins_dir() -> Optional[Path]: """Return ``$HERMES_HOME/plugins/`` or None if unavailable.""" try: @@ -308,10 +327,10 @@ index cccda75ce8..f08e41aa37 100644 return d if d.is_dir() else None except Exception: diff --git a/providers/__init__.py b/providers/__init__.py -index a394e74b33..8c9144c90f 100644 +index 011e84a..f50d326 100644 --- a/providers/__init__.py +++ b/providers/__init__.py -@@ -91,7 +91,9 @@ def list_providers() -> list[ProviderProfile]: +@@ -100,7 +100,9 @@ def list_providers() -> list[ProviderProfile]: def _user_plugins_dir() -> Path | None: """Return ``$HERMES_HOME/plugins/model-providers/`` if it exists.""" try: @@ -323,10 +342,10 @@ index a394e74b33..8c9144c90f 100644 d = get_hermes_home() / "plugins" / "model-providers" return d if d.is_dir() else None diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py -index ec5692ecd5..943f1904db 100644 +index 05ff08d..37b12d6 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py -@@ -322,6 +322,10 @@ def _lazy_install_target() -> Optional[Path]: +@@ -403,6 +403,10 @@ def _lazy_install_target() -> Optional[Path]: Returns a path only when :data:`_LAZY_TARGET_ENV` is set to a non-empty value. The directory is created on demand by :func:`_ensure_target_ready`. """ @@ -337,7 +356,7 @@ index ec5692ecd5..943f1904db 100644 raw = os.environ.get(_LAZY_TARGET_ENV, "").strip() if not raw: return None -@@ -654,20 +658,37 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install +@@ -735,10 +739,26 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install if constraints is not None: constraint_args = ["--constraint", str(constraints)] @@ -364,38 +383,50 @@ index ec5692ecd5..943f1904db 100644 uv_env["VIRTUAL_ENV"] = str(venv_root) # Tier 1: uv (preferred — fast, doesn't need pip in the venv) -- uv_bin = shutil.which("uv") -+ uv_bin = "/usr/local/bin/uv" if managed_gateway else shutil.which("uv") +@@ -751,9 +771,13 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install + try: + from hermes_cli.managed_uv import resolve_uv + +- uv_bin = resolve_uv() or shutil.which("uv") ++ uv_bin = ( ++ "/usr/local/bin/uv" ++ if managed_gateway ++ else (resolve_uv() or shutil.which("uv")) ++ ) + except Exception: +- uv_bin = shutil.which("uv") ++ uv_bin = "/usr/local/bin/uv" if managed_gateway else shutil.which("uv") if uv_bin: try: r = subprocess.run( - [uv_bin, "pip", "install", *target_args, *constraint_args, *specs], - capture_output=True, text=True, timeout=timeout, env=uv_env, +@@ -761,6 +785,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout, env=uv_env, stdin=subprocess.DEVNULL, + creationflags=windows_hide_flags(), + cwd=trusted_cwd, ) if r.returncode == 0: if target is not None: -@@ -684,6 +705,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install - pip_cmd + ["--version"], - capture_output=True, text=True, timeout=15, +@@ -788,6 +813,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=15, stdin=subprocess.DEVNULL, + creationflags=windows_hide_flags(), + env=pip_env, cwd=trusted_cwd, ) if probe.returncode != 0: raise FileNotFoundError("pip not in venv") -@@ -693,6 +715,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install - [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], - capture_output=True, text=True, timeout=120, check=True, +@@ -798,6 +824,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=120, check=True, stdin=subprocess.DEVNULL, + creationflags=windows_hide_flags(), + env=pip_env, cwd=trusted_cwd, ) except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: return _InstallResult(False, "", -@@ -703,6 +726,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install - pip_cmd + ["install", *target_args, *constraint_args, *specs], - capture_output=True, text=True, timeout=timeout, +@@ -809,6 +836,7 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout, stdin=subprocess.DEVNULL, + creationflags=windows_hide_flags(), + env=pip_env, cwd=trusted_cwd, ) if r.returncode == 0 and target is not None: diff --git a/agents/hermes/secure-dir-skip-chmod.patch b/agents/hermes/secure-dir-skip-chmod.patch new file mode 100644 index 00000000000..f6f91a11a18 --- /dev/null +++ b/agents/hermes/secure-dir-skip-chmod.patch @@ -0,0 +1,16 @@ +diff --git a/hermes_cli/config.py b/hermes_cli/config.py +index ef939e1..3cec737 100644 +--- a/hermes_cli/config.py ++++ b/hermes_cli/config.py +@@ -844,3 +844,4 @@ def _secure_dir(path): +- Skipped in managed mode — the NixOS module sets group-readable +- permissions (0750) so interactive users in the hermes group can ++ Skipped in managed mode or when HERMES_SKIP_CHMOD is set. The NixOS ++ module sets group-readable permissions (0750), while container deployments ++ can provision their own shared-directory modes, so interactive users can + share state with the gateway service. +@@ -858,3 +859,3 @@ def _secure_dir(path): + """ +- if is_managed(): ++ if is_managed() or os.environ.get("HERMES_SKIP_CHMOD"): + return diff --git a/agents/hermes/security-dependencies.patch b/agents/hermes/security-dependencies.patch index aebd6cc48bc..246dbe1ec50 100644 --- a/agents/hermes/security-dependencies.patch +++ b/agents/hermes/security-dependencies.patch @@ -1,123 +1,19 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Applies reviewed dependency fixes to the checksum-pinned Hermes v2026.7.20 -# source metadata and frozen lock before installation. -diff --git a/pyproject.toml b/pyproject.toml -index c630b3c..fd28f6a 100644 ---- a/pyproject.toml -+++ b/pyproject.toml -@@ -89,7 +89,10 @@ dependencies = [ - "urllib3>=2.7.0,<3", - # cryptography is pulled in transitively by PyJWT[crypto]; pin it explicitly - # so the WeCom/Weixin crypto paths can't drift below the CVE-fixed floor. -- "cryptography==46.0.7", # CVE-2026-39892, CVE-2026-34073 -+ "cryptography==50.0.0", # GHSA-g6cj-pr64-35w5 -+ # Tornado 6.5.7 fixes GHSA-3x9g-8vmp-wqvf, GHSA-mgf9-4vpg-hj56, -+ # and GHSA-pw6j-qg29-8w7f. -+ "tornado==6.5.7", - # Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo`` - # (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone - # out of the box. ``tzdata`` ships the Olson database as a data package -@@ -123,7 +126,7 @@ dependencies = [ - # libs required for the codecs we use, so it's safe to ship in the base - # install rather than gating it behind an extra + a mid-session lazy install - # (which deadlocked the CLI under prompt_toolkit — see #40490). -- "Pillow==12.2.0", -+ "Pillow==12.3.0", - # Windows log rotation. Stdlib ``RotatingFileHandler.doRollover()`` uses - # ``os.rename()`` which fails with ``PermissionError [WinError 32]`` on - # Windows whenever any other process holds an append-mode handle on -@@ -157,11 +160,11 @@ edge-tts = ["edge-tts==7.2.7"] - modal = ["modal==1.3.4"] - daytona = ["daytona==0.155.0"] - hindsight = ["hindsight-client==0.6.1"] --dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"] # starlette: CVE-2026-48710; setuptools: latest <82 (torch >=2.11 caps setuptools<82) --messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.14.1", "brotlicffi==1.2.0.1", "slack-bolt==1.29.0", "slack-sdk==3.43.0", "qrcode==7.4.2"] # aiohttp 3.14.1: CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 -+dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.28.1", "starlette==1.3.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"] # starlette: GHSA-82w8-qh3p-5jfq; setuptools: latest <82 (torch >=2.11 caps setuptools<82) -+messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.14.3", "brotlicffi==1.2.0.1", "slack-bolt==1.29.0", "slack-sdk==3.43.0", "qrcode==7.4.2"] # aiohttp 3.14.3: GHSA-cq5v-8q36-5273 - cron = [] # croniter is now a core dependency; this extra kept for back-compat --slack = ["slack-bolt==1.29.0", "slack-sdk==3.43.0", "aiohttp==3.14.1"] --matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0", "aiohttp==3.14.1"] # aiohttp 3.14.1: CVE-2026-34993(RCE)/47265 + 34513/34518/34519/34520/34525 (mautrix/aiohttp-socks only cap aiohttp<4 / >=3.10, so pin the patched floor directly) -+slack = ["slack-bolt==1.29.0", "slack-sdk==3.43.0", "aiohttp==3.14.3"] -+matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0", "aiohttp==3.14.3"] # aiohttp 3.14.3: GHSA-cq5v-8q36-5273 - # WeCom callback-mode adapter — parses untrusted XML POST bodies from - # WeCom-controlled callback endpoints, so we use defusedxml (drop-in - # replacement for stdlib xml.etree.ElementTree) to block billion-laughs -@@ -204,16 +207,16 @@ vision = [] - # `request.url` can be bypassed. We pin a patched Starlette directly in every - # extra that exposes a Starlette-backed server surface so pip/uv can't resolve - # a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock. --mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710 -+mcp = ["mcp==1.28.1", "starlette==1.3.1"] # starlette: GHSA-82w8-qh3p-5jfq - nemo-relay = ["nemo-relay>=0.5,<1.0"] --homeassistant = ["aiohttp==3.14.1"] --sms = ["aiohttp==3.14.1"] --teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.1"] # aiohttp 3.14.1: CVE-2026-34993(RCE)/47265 + 34513/34518/34519/34520/34525 -+homeassistant = ["aiohttp==3.14.3"] -+sms = ["aiohttp==3.14.3"] -+teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.3"] # aiohttp 3.14.3: GHSA-cq5v-8q36-5273 - # Computer use — macOS background desktop control via cua-driver (MCP stdio). - # The cua-driver binary itself is installed via `hermes tools` post-setup - # (curl install script); this extra just pins the MCP client used to talk - # to it, which is already provided by the `mcp` extra. --computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710 -+computer-use = ["mcp==1.28.1", "starlette==1.3.1"] # starlette: GHSA-82w8-qh3p-5jfq - acp = ["agent-client-protocol==0.9.0"] - # mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version. - # The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious -@@ -248,7 +251,9 @@ termux-all = [ - "hermes-agent[sms]", - "hermes-agent[web]", - ] --dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"] -+# 2.2.54 removes the cryptography<49 tea-openapi dependency cap so the full -+# release lock remains compatible with the core cryptography security pin. -+dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.54", "qrcode==7.4.2"] - feishu = ["lark-oapi==1.6.8", "qrcode==7.4.2"] - google = [ - # Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts, -@@ -267,9 +272,9 @@ youtube = [ - "youtube-transcript-api==1.2.4", - ] - # `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean. --# starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette --# transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above. --web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1", "python-multipart==0.0.27"] -+# starlette==1.3.1 pinned for GHSA-82w8-qh3p-5jfq because FastAPI pulls -+# Starlette transitively. Keep the patched floor aligned with the mcp extra. -+web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.3.1", "python-multipart==0.0.27"] - all = [ - # Policy (2026-05-12): `[all]` includes only extras that genuinely - # CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every -diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py -index f6345d2..5bf65a4 100644 ---- a/hermes_cli/memory_setup.py -+++ b/hermes_cli/memory_setup.py -@@ -123,1 +123,17 @@ def _install_dependencies(provider_name: str) -> None: -+ # NemoClaw seals the Hermes venv and redirects this reviewed dependency -+ # to a durable sandbox-owned target. Reuse the allowlisted lazy installer -+ # so setup never falls back to mutating /opt/hermes or resolving a looser -+ # plugin.yaml range. The setup command itself runs as the sandbox user. -+ if provider_name == "hindsight": -+ from tools.lazy_deps import FeatureUnavailable, ensure -+ -+ try: -+ ensure("memory.hindsight", prompt=False) -+ except FeatureUnavailable as exc: -+ raise RuntimeError( -+ "Hindsight dependency installation failed through the managed lazy target" -+ ) from exc -+ print(" Installed hindsight-client==0.6.1") -+ return -+ - print(f"\n Installing dependencies: {', '.join(missing)}") +# Pins the remaining NemoClaw source compatibility differences in the checksum- +# pinned Hermes v2026.8.27 source. Hermes 0.20.6 already carries the reviewed +# Python security floors and routes memory-provider installation through its +# managed lazy target. Keep the Hindsight bridge and upstream agent-browser +# fallback exact so runtime resolution cannot drift beyond the separately +# verified build-time probes. Scope one-shot completion waits to the exact turn +# so unrelated managed-runtime processes cannot hold a completed query open. +# Keep the cron tick lock in writable runtime state while Shields keeps cron +# definitions sealed read-only. diff --git a/plugins/memory/hindsight/plugin.yaml b/plugins/memory/hindsight/plugin.yaml -index 5b37014..f763fd5 100644 --- a/plugins/memory/hindsight/plugin.yaml +++ b/plugins/memory/hindsight/plugin.yaml -@@ -1,7 +1,7 @@ - name: hindsight +@@ -2,7 +2,7 @@ name: hindsight version: 1.0.0 description: "Hindsight — long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval." pip_dependencies: @@ -125,604 +21,55 @@ index 5b37014..f763fd5 100644 + - "hindsight-client==0.6.1" requires_env: [] hooks: -diff --git a/uv.lock b/uv.lock -index 257f7d6..d2f7607 100644 ---- a/uv.lock -+++ b/uv.lock -@@ -39,7 +39,7 @@ wheels = [ - - [[package]] - name = "aiohttp" --version = "3.14.1" -+version = "3.14.3" - source = { registry = "https://pypi.org/simple" } - dependencies = [ - { name = "aiohappyeyeballs" }, -@@ -51,67 +51,67 @@ dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "yarl" }, - ] --sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } --wheels = [ -- { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, -- { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, -- { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, -- { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, -- { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, -- { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, -- { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, -- { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, -- { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, -- { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, -- { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, -- { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, -- { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, -- { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, -- { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, -- { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, -- { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, -- { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, -- { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, -- { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, -- { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, -- { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, -- { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, -- { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, -- { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, -- { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, -- { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, -- { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, -- { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, -- { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, -- { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, -- { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, -- { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, -- { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, -- { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, -- { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, -- { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, -- { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, -- { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, -- { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, -- { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, -- { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, -- { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, -- { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, -- { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, -- { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, -- { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, -- { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, -- { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, -- { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, -- { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, -- { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, -- { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, -- { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, -- { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, -- { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, -- { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, -- { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, -- { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, -+sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } -+wheels = [ -+ { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, -+ { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, -+ { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, -+ { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, -+ { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, -+ { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, -+ { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, -+ { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, -+ { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, -+ { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, -+ { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, -+ { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, -+ { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, -+ { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, -+ { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, -+ { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, -+ { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, -+ { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, -+ { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, -+ { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, -+ { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, -+ { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, -+ { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, -+ { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, -+ { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, -+ { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, -+ { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, -+ { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, -+ { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, -+ { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, -+ { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, -+ { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, -+ { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, -+ { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, -+ { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, -+ { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, -+ { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, -+ { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, -+ { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, -+ { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, -+ { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, -+ { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, -+ { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, -+ { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, -+ { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, -+ { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, -+ { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, -+ { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, -+ { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, -+ { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, -+ { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, -+ { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, -+ { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, -+ { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, -+ { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, -+ { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, -+ { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, -+ { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, -+ { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, - ] - - [[package]] -@@ -184,7 +184,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/a0/87/1d7019d23891897cb - - [[package]] - name = "alibabacloud-dingtalk" --version = "2.2.42" -+version = "2.2.54" - source = { registry = "https://pypi.org/simple" } - dependencies = [ - { name = "alibabacloud-endpoint-util" }, -@@ -194,9 +194,9 @@ dependencies = [ - { name = "alibabacloud-tea-openapi" }, - { name = "alibabacloud-tea-util" }, - ] --sdist = { url = "https://files.pythonhosted.org/packages/38/66/36efc03a2a8ed16c2ce176fd5ab6ff9725d0048aef33eaf867e85e625401/alibabacloud_dingtalk-2.2.42.tar.gz", hash = "sha256:220b1d52f5ef82a23ea625d3c8a91a733a685417248e217cf5aa30fe0b3a8978", size = 2023797, upload-time = "2026-04-10T03:58:28.143Z" } -+sdist = { url = "https://files.pythonhosted.org/packages/1d/70/184a62b7271abe38f856c08131bc61b7a7e0ded006cbf39da9c76d6c2335/alibabacloud_dingtalk-2.2.54.tar.gz", hash = "sha256:c97fd332dbebf12e6826394f05767c25f6755d76212a60f67db0d1f99965b4f4", size = 2113073, upload-time = "2026-07-23T07:07:08.754Z" } - wheels = [ -- { url = "https://files.pythonhosted.org/packages/9d/80/7d1c1438e17c1fc90d037f1b73debe3fc2dfa348eb91e12818c2584d1865/alibabacloud_dingtalk-2.2.42-py3-none-any.whl", hash = "sha256:5f5c2ef3351b7926eb870af11089e14f802e4caa51d5f72920ad79a67f03d3e4", size = 2142688, upload-time = "2026-04-10T03:58:26.33Z" }, -+ { url = "https://files.pythonhosted.org/packages/3b/6f/ae53e468af95aacd68f8251f2d567fa16d520eac349e9952419b531dfbda/alibabacloud_dingtalk-2.2.54-py3-none-any.whl", hash = "sha256:67b4f1c70ad17fb7f1611d3594130c5318a047b1ddbb1506c21249fffaac4a9b", size = 2235207, upload-time = "2026-07-23T07:07:06.918Z" }, - ] - - [[package]] -@@ -217,12 +217,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/d2/40/751d8bdf133d7fcf0 - - [[package]] - name = "alibabacloud-gateway-spi" --version = "0.0.3" -+version = "0.0.4" - source = { registry = "https://pypi.org/simple" } - dependencies = [ - { name = "alibabacloud-credentials" }, - ] --sdist = { url = "https://files.pythonhosted.org/packages/ab/98/d7111245f17935bf72ee9bea60bbbeff2bc42cdfe24d2544db52bc517e1a/alibabacloud_gateway_spi-0.0.3.tar.gz", hash = "sha256:10d1c53a3fc5f87915fbd6b4985b98338a776e9b44a0263f56643c5048223b8b", size = 4249, upload-time = "2025-02-23T16:29:54.222Z" } -+sdist = { url = "https://files.pythonhosted.org/packages/cd/67/7cd36a36bf0ec53efd3fe31eaf398713f375b7b1cdcd32c51b6c0fdc09e2/alibabacloud_gateway_spi-0.0.4.tar.gz", hash = "sha256:73d6e20d65b54eed26d89c19640d3a7572e18c45ecada627f806f5dbe8ed2130", size = 4253, upload-time = "2026-06-25T06:34:08.238Z" } -+wheels = [ -+ { url = "https://files.pythonhosted.org/packages/45/85/81170c45e9d1240736f9776a1c690b958c975509381221b5f8d960b5376d/alibabacloud_gateway_spi-0.0.4-py3-none-any.whl", hash = "sha256:0d5256e95d8719da8ec9611b7ffbb12c2d26fdf7ce52c8f64e4e06350731e893", size = 4290, upload-time = "2026-06-25T06:34:07.22Z" }, -+] - - [[package]] - name = "alibabacloud-openapi-util" -@@ -249,19 +252,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0 - - [[package]] - name = "alibabacloud-tea-openapi" --version = "0.4.4" -+version = "0.3.16" - source = { registry = "https://pypi.org/simple" } - dependencies = [ - { name = "alibabacloud-credentials" }, - { name = "alibabacloud-gateway-spi" }, -+ { name = "alibabacloud-openapi-util" }, - { name = "alibabacloud-tea-util" }, -- { name = "cryptography" }, -- { name = "darabonba-core" }, --] --sdist = { url = "https://files.pythonhosted.org/packages/30/93/138bcdc8fc596add73e37cf2073798f285284d1240bda9ee02f9384fc6be/alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce", size = 21960, upload-time = "2026-03-26T10:16:16.792Z" } --wheels = [ -- { url = "https://files.pythonhosted.org/packages/f5/5a/6bfc4506438c1809c486f66217ad11eab78157192b3d5707b4e2f4212f6c/alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f", size = 26236, upload-time = "2026-03-26T10:16:15.861Z" }, -+ { name = "alibabacloud-tea-xml" }, - ] -+sdist = { url = "https://files.pythonhosted.org/packages/09/be/f594e79625e5ccfcfe7f12d7d70709a3c59e920878469c998886211c850d/alibabacloud_tea_openapi-0.3.16.tar.gz", hash = "sha256:6bffed8278597592e67860156f424bde4173a6599d7b6039fb640a3612bae292", size = 13087, upload-time = "2025-07-04T09:30:10.689Z" } - - [[package]] - name = "alibabacloud-tea-util" -@@ -275,6 +275,15 @@ wheels = [ - { url = "https://files.pythonhosted.org/packages/72/9e/c394b4e2104766fb28a1e44e3ed36e4c7773b4d05c868e482be99d5635c9/alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe", size = 6697, upload-time = "2025-11-19T06:01:07.355Z" }, - ] - -+[[package]] -+name = "alibabacloud-tea-xml" -+version = "0.0.3" -+source = { registry = "https://pypi.org/simple" } -+dependencies = [ -+ { name = "alibabacloud-tea" }, -+] -+sdist = { url = "https://files.pythonhosted.org/packages/32/eb/5e82e419c3061823f3feae9b5681588762929dc4da0176667297c2784c1a/alibabacloud_tea_xml-0.0.3.tar.gz", hash = "sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c", size = 3466, upload-time = "2025-07-01T08:04:55.144Z" } -+ - [[package]] - name = "annotated-doc" - version = "0.0.4" -@@ -721,47 +730,45 @@ wheels = [ - - [[package]] - name = "cryptography" --version = "46.0.7" -+version = "50.0.0" - source = { registry = "https://pypi.org/simple" } - dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - ] --sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } --wheels = [ -- { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, -- { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, -- { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, -- { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, -- { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, -- { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, -- { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, -- { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, -- { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, -- { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, -- { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, -- { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, -- { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, -- { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, -- { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, -- { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, -- { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, -- { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, -- { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, -- { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, -- { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, -- { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, -- { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, -- { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, -- { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, -- { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, -- { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, -- { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, -- { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, -- { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, -- { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, -- { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, -- { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, -- { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, -+sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } -+wheels = [ -+ { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, -+ { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, -+ { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, -+ { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, -+ { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, -+ { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, -+ { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, -+ { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, -+ { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, -+ { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, -+ { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, -+ { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, -+ { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, -+ { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, -+ { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, -+ { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, -+ { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, -+ { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, -+ { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, -+ { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, -+ { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, -+ { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, -+ { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, -+ { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, -+ { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, -+ { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, -+ { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, -+ { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, -+ { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, -+ { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, -+ { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, -+ { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, - ] - - [[package]] -@@ -791,19 +798,6 @@ wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/15/6e8e87c6a201d69803a79ac2e29623ce7c2cc9cd1df9db99810cca714373/ctranslate2-4.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:baa6d2b10f57933d8c11791e8522659217918722d07bbef2389a443801125fe7", size = 18844953, upload-time = "2026-02-04T06:11:58.519Z" }, - ] - --[[package]] --name = "darabonba-core" --version = "1.0.5" --source = { registry = "https://pypi.org/simple" } --dependencies = [ -- { name = "aiohttp" }, -- { name = "alibabacloud-tea" }, -- { name = "requests" }, --] --wheels = [ -- { url = "https://files.pythonhosted.org/packages/66/d3/a7daaee544c904548e665829b51a9fa2572acb82c73ad787a8ff90273002/darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c", size = 24580, upload-time = "2025-12-12T07:53:59.494Z" }, --] + - on_session_end +diff --git a/tools/browser_tool.py b/tools/browser_tool.py +--- a/tools/browser_tool.py ++++ b/tools/browser_tool.py +@@ -957,8 +957,7 @@ NPX_AGENT_BROWSER_SENTINEL = "npx agent-browser" +-# Pinned to match scripts/install.sh / scripts/install.ps1's +-# "agent-browser@^0.26.0" managed install so a git-clone install resolving +-# agent-browser via bare npx gets the same version as a managed install, +-# instead of floating latest with no integrity check. Update both together. +-AGENT_BROWSER_NPX_SPEC = "agent-browser@^0.26.0" - - [[package]] - name = "davey" - version = "0.1.4" -@@ -1545,6 +1539,7 @@ dependencies = [ - { name = "rich" }, - { name = "ruamel-yaml" }, - { name = "tenacity" }, -+ { name = "tornado" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, - { name = "urllib3" }, - { name = "uvicorn", extra = ["standard"] }, -@@ -1733,15 +1728,15 @@ youtube = [ - [package.metadata] - requires-dist = [ - { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" }, -- { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.14.1" }, -- { name = "aiohttp", marker = "extra == 'matrix'", specifier = "==3.14.1" }, -- { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.14.1" }, -- { name = "aiohttp", marker = "extra == 'slack'", specifier = "==3.14.1" }, -- { name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.14.1" }, -- { name = "aiohttp", marker = "extra == 'teams'", specifier = "==3.14.1" }, -+ { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.14.3" }, -+ { name = "aiohttp", marker = "extra == 'matrix'", specifier = "==3.14.3" }, -+ { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.14.3" }, -+ { name = "aiohttp", marker = "extra == 'slack'", specifier = "==3.14.3" }, -+ { name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.14.3" }, -+ { name = "aiohttp", marker = "extra == 'teams'", specifier = "==3.14.3" }, - { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" }, - { name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" }, -- { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" }, -+ { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.54" }, - { name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.87.0" }, - { name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" }, - { name = "azure-identity", marker = "extra == 'azure-identity'", specifier = "==1.25.3" }, -@@ -1750,7 +1745,7 @@ requires-dist = [ - { name = "certifi", specifier = "==2026.5.20" }, - { name = "concurrent-log-handler", marker = "sys_platform == 'win32'", specifier = "==0.9.29" }, - { name = "croniter", specifier = "==6.0.0" }, -- { name = "cryptography", specifier = "==46.0.7" }, -+ { name = "cryptography", specifier = "==50.0.0" }, - { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, - { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" }, - { name = "defusedxml", marker = "extra == 'wecom'", specifier = "==0.7.1" }, -@@ -1797,9 +1792,9 @@ requires-dist = [ - { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.6.8" }, - { name = "markdown", specifier = "==3.10.2" }, - { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" }, -- { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" }, -- { name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" }, -- { name = "mcp", marker = "extra == 'mcp'", specifier = "==1.26.0" }, -+ { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.28.1" }, -+ { name = "mcp", marker = "extra == 'dev'", specifier = "==1.28.1" }, -+ { name = "mcp", marker = "extra == 'mcp'", specifier = "==1.28.1" }, - { name = "mem0ai", marker = "extra == 'mem0'", specifier = "==2.0.10" }, - { name = "microsoft-teams-apps", marker = "extra == 'teams'", specifier = "==2.0.13.4" }, - { name = "mistralai", marker = "extra == 'mistral'", specifier = "==2.4.8" }, -@@ -1810,7 +1805,7 @@ requires-dist = [ - { name = "packaging", specifier = "==26.0" }, - { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" }, - { name = "pathspec", specifier = "==1.1.1" }, -- { name = "pillow", specifier = "==12.2.0" }, -+ { name = "pillow", specifier = "==12.3.0" }, - { name = "prompt-toolkit", specifier = "==3.0.52" }, - { name = "psutil", specifier = "==7.2.2" }, - { name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" }, -@@ -1839,12 +1834,13 @@ requires-dist = [ - { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.43.0" }, - { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.43.0" }, - { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" }, -- { name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.0.1" }, -- { name = "starlette", marker = "extra == 'dev'", specifier = "==1.0.1" }, -- { name = "starlette", marker = "extra == 'mcp'", specifier = "==1.0.1" }, -- { name = "starlette", marker = "extra == 'web'", specifier = "==1.0.1" }, -+ { name = "starlette", marker = "extra == 'computer-use'", specifier = "==1.3.1" }, -+ { name = "starlette", marker = "extra == 'dev'", specifier = "==1.3.1" }, -+ { name = "starlette", marker = "extra == 'mcp'", specifier = "==1.3.1" }, -+ { name = "starlette", marker = "extra == 'web'", specifier = "==1.3.1" }, - { name = "supermemory", marker = "extra == 'supermemory'", specifier = "==3.50.0" }, - { name = "tenacity", specifier = "==9.1.4" }, -+ { name = "tornado", specifier = "==6.5.7" }, - { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" }, - { name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" }, - { name = "urllib3", specifier = ">=2.7.0,<3" }, -@@ -2303,7 +2299,7 @@ encryption = [ - - [[package]] - name = "mcp" --version = "1.26.0" -+version = "1.28.1" - source = { registry = "https://pypi.org/simple" } - dependencies = [ - { name = "anyio" }, -@@ -2321,9 +2317,9 @@ dependencies = [ - { name = "typing-inspection" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, - ] --sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } -+sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } - wheels = [ -- { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, -+ { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, - ] - - [[package]] -@@ -2466,16 +2462,16 @@ wheels = [ - - [[package]] - name = "msal" --version = "1.36.0" -+version = "1.37.0" - source = { registry = "https://pypi.org/simple" } - dependencies = [ - { name = "cryptography" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "requests" }, - ] --sdist = { url = "https://files.pythonhosted.org/packages/de/cb/b02b0f748ac668922364ccb3c3bff5b71628a05f5adfec2ba2a5c3031483/msal-1.36.0.tar.gz", hash = "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b", size = 174217, upload-time = "2026-04-09T10:20:33.525Z" } -+sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } - wheels = [ -- { url = "https://files.pythonhosted.org/packages/2a/d3/414d1f0a5f6f4fe5313c2b002c54e78a3332970feb3f5fed14237aa17064/msal-1.36.0-py3-none-any.whl", hash = "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", size = 121547, upload-time = "2026-04-09T10:20:32.336Z" }, -+ { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" }, - ] - - [[package]] -@@ -2953,64 +2949,45 @@ wheels = [ - - [[package]] - name = "pillow" --version = "12.2.0" --source = { registry = "https://pypi.org/simple" } --sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } --wheels = [ -- { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, -- { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, -- { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, -- { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, -- { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, -- { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, -- { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, -- { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, -- { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, -- { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, -- { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, -- { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, -- { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, -- { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, -- { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, -- { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, -- { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, -- { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, -- { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, -- { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, -- { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, -- { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, -- { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, -- { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, -- { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, -- { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, -- { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, -- { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, -- { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, -- { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, -- { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, -- { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, -- { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, -- { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, -- { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, -- { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, -- { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, -- { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, -- { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, -- { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, -- { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, -- { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, -- { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, -- { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, -- { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, -- { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, -- { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, -- { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, -- { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, -- { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, -- { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, -- { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, -- { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, -- { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, -+version = "12.3.0" -+source = { registry = "https://pypi.org/simple" } -+sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } -+wheels = [ -+ { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, -+ { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, -+ { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, -+ { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, -+ { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, -+ { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, -+ { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, -+ { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, -+ { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, -+ { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, -+ { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, -+ { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, -+ { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, -+ { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, -+ { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, -+ { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, -+ { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, -+ { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, -+ { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, -+ { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, -+ { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, -+ { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, -+ { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, -+ { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, -+ { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, -+ { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, -+ { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, -+ { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, -+ { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, -+ { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, -+ { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, -+ { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, -+ { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, -+ { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, -+ { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, - ] - - [[package]] -@@ -3991,15 +3968,15 @@ wheels = [ - - [[package]] - name = "starlette" --version = "1.0.1" -+version = "1.3.1" - source = { registry = "https://pypi.org/simple" } - dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - ] --sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } -+sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } - wheels = [ -- { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, -+ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, - ] - - [[package]] -@@ -4107,19 +4084,19 @@ wheels = [ - - [[package]] - name = "tornado" --version = "6.5.5" --source = { registry = "https://pypi.org/simple" } --sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } --wheels = [ -- { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, -- { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, -- { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, -- { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, -- { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, -- { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, -- { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, -- { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, -- { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, -+version = "6.5.7" -+source = { registry = "https://pypi.org/simple" } -+sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } -+wheels = [ -+ { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, -+ { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, -+ { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, -+ { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, -+ { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, -+ { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, -+ { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, -+ { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, -+ { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, - ] - - [[package]] +- ++# NemoClaw pins the npx fallback to the reviewed 0.26.0 release so managed ++# image execution cannot float to a later registry version. Update the image ++# warm-cache and offline probes with this source authority. ++AGENT_BROWSER_NPX_SPEC = "agent-browser@0.26.0" ++ ++ + def _is_npx_agent_browser_sentinel(browser_cmd: str) -> bool: +diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py +--- a/hermes_cli/oneshot.py ++++ b/hermes_cli/oneshot.py +@@ -23,6 +23,7 @@ from __future__ import annotations + import logging + import os + import sys ++import uuid + from contextlib import redirect_stderr, redirect_stdout + from pathlib import Path + from typing import Optional +@@ -510,4 +511,5 @@ def _run_agent( +- result = agent.run_conversation(prompt) ++ oneshot_task_id = str(uuid.uuid4()) ++ result = agent.run_conversation(prompt, task_id=oneshot_task_id) + return (result.get("final_response") or "", result) + finally: + # Ordering deliberately mirrors gateway/run.py:_cleanup_agent_resources, +@@ -526,4 +528,4 @@ def _run_agent( +- process_registry.wait_for_pending_completions(None) ++ process_registry.wait_for_pending_completions(oneshot_task_id) + except Exception: + logging.debug("oneshot background completion wait failed", exc_info=True) + try: +diff --git a/cron/scheduler.py b/cron/scheduler.py +--- a/cron/scheduler.py ++++ b/cron/scheduler.py +@@ -1569,4 +1569,4 @@ def _get_lock_paths() -> tuple[Path, Path]: + """Resolve cron lock paths at call time so profile/env changes are honored.""" + hermes_home = _get_hermes_home() +- lock_dir = hermes_home / "cron" ++ lock_dir = hermes_home / "runtime" + return lock_dir, lock_dir / ".tick.lock" diff --git a/agents/hermes/validate-env-secret-boundary.py b/agents/hermes/validate-env-secret-boundary.py index 6906fe4bdf6..63aa78f78b6 100755 --- a/agents/hermes/validate-env-secret-boundary.py +++ b/agents/hermes/validate-env-secret-boundary.py @@ -38,6 +38,8 @@ HERMES_API_PORT_RANGE_END = 8652 MANAGED_HERMES_HOME = "/sandbox/.hermes" MANAGED_BUNDLED_PLUGINS = "/opt/hermes/plugins" +SANDBOX_LAZY_INSTALL_TARGET = "/sandbox/.hermes/lazy-packages" +GATEWAY_LAZY_INSTALL_TARGET = "/run/nemoclaw/hermes-gateway-lazy-packages" ENV_FILE_DENIED_CONTROL_KEYS = frozenset( { "BASH_ENV", @@ -145,15 +147,15 @@ def _sandbox_identity() -> tuple[int, int] | None: def _expected_lazy_install_target() -> str: effective_uid = os.geteuid() if effective_uid == 0: - return "/run/nemoclaw/hermes-gateway-lazy-packages" + return GATEWAY_LAZY_INSTALL_TARGET try: if effective_uid == pwd.getpwnam("gateway").pw_uid: - return "/run/nemoclaw/hermes-gateway-lazy-packages" + return GATEWAY_LAZY_INSTALL_TARGET except KeyError: # Development hosts commonly have no gateway account; their current # user exercises the same-identity sandbox contract. pass - return "/sandbox/.hermes/lazy-packages" + return SANDBOX_LAZY_INSTALL_TARGET def _validate_env_file_metadata(path: str, st: os.stat_result) -> None: @@ -537,11 +539,9 @@ def validate_env_file(path: str) -> int: return 1 -def validate_runtime_env(env: dict[str, str] | None = None) -> int: - source = os.environ if env is None else env +def _validate_runtime_env(source: dict[str, str], expected_lazy_target: str) -> int: violations: list[str] = [] violation_count = 0 - expected_lazy_target = _expected_lazy_install_target() if source.get("HERMES_LAZY_INSTALL_TARGET") != expected_lazy_target: violation_count += 1 if len(violations) < MAX_VIOLATIONS: @@ -603,6 +603,27 @@ def validate_runtime_env(env: dict[str, str] | None = None) -> int: return 1 +def validate_runtime_env(env: dict[str, str] | None = None) -> int: + source = os.environ if env is None else env + return _validate_runtime_env(source, _expected_lazy_install_target()) + + +def validate_managed_gateway_env(supervisor_env: dict[str, str]) -> int: + """Validate the environment that the managed launcher gives Hermes.""" + + runtime_env = dict(supervisor_env) + # PID 1 exposes the shell's initial environment. The trusted launcher pins + # these values after startup and again on the Hermes gateway command. + runtime_env.update( + { + "HERMES_LAZY_INSTALL_TARGET": GATEWAY_LAZY_INSTALL_TARGET, + "HERMES_HOME": MANAGED_HERMES_HOME, + "HERMES_BUNDLED_PLUGINS": MANAGED_BUNDLED_PLUGINS, + } + ) + return _validate_runtime_env(runtime_env, GATEWAY_LAZY_INSTALL_TARGET) + + # Config-output masking layer for the wrapper-installed `hermes config show` # path. The upstream Hermes CLI prints inline provider `api_key` values verbatim # when asked to render the resolved configuration, so the wrapper pipes the diff --git a/agents/hermes/whatsapp-proxy.patch b/agents/hermes/whatsapp-proxy.patch index 5c53947f6af..38ba8e4b642 100644 --- a/agents/hermes/whatsapp-proxy.patch +++ b/agents/hermes/whatsapp-proxy.patch @@ -1,25 +1,25 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Routes the pinned Hermes v2026.7.20 WhatsApp bridge through the OpenShell -# proxy and keeps dashboard pairing state in the gateway's session directory. -# Remove the web_server.py hunk when the minimum supported Hermes release stores -# Dashboard pairing state in the gateway session directory natively. -# Remove the scripts/whatsapp-bridge hunks when that release routes the WhatsApp -# bridge through HTTPS_PROXY natively. +# Routes the pinned Hermes v2026.8.27 WhatsApp bridge through the OpenShell +# proxy and keeps dashboard pairing state in the gateway session directory. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py -index d0c78a6b3..5f6d801b5 100644 +index 2b5f0fd6..275011f7 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py -@@ -8110,5 +8110,3 @@ def _normalize_whatsapp_allowed_users(value: Any) -> str: +@@ -9723,7 +9723,5 @@ def _whatsapp_session_path() -> Path: - from hermes_constants import get_hermes_dir - - return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") +- +- + return Path("/sandbox/.hermes/platforms/whatsapp/session") - ++ ++ + def _whatsapp_phone_from_identifier(value: Any) -> str | None: diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js -index 4b5733d16..22f1ec3e1 100644 +index 234cbef2..0136628d 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -30,6 +30,7 @@ import { randomBytes, createHash } from 'crypto'; @@ -30,28 +30,108 @@ index 4b5733d16..22f1ec3e1 100644 import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js'; import { createOutboundIdTracker } from './outbound_ids.js'; import { classifyOwnerMessageGate } from './owner_message_gate.js'; -@@ -378,6 +379,9 @@ function rememberSentId(id) { - - let sock = null; +@@ -389,3 +389,6 @@ let connectionState = 'disconnected'; +- +const PROXY_AGENT = process.env.HTTPS_PROXY + ? new HttpsProxyAgent(process.env.HTTPS_PROXY) + : undefined; - ++ function emitPairEvent(event) { - if (!PAIR_JSON) return; -@@ -393,6 +397,8 @@ async function startSocket() { +@@ -405,6 +409,8 @@ async function startSocket() { sock = makeWASocket({ - version, + ...(version ? { version } : {}), auth: state, + agent: PROXY_AGENT, + fetchAgent: PROXY_AGENT, logger, printQRInTerminal: false, browser: ['Hermes Agent', 'Chrome', '120.0'], +diff --git a/scripts/whatsapp-bridge/package-lock.json b/scripts/whatsapp-bridge/package-lock.json +index 74fe2df3..7a023bfa 100644 +--- a/scripts/whatsapp-bridge/package-lock.json ++++ b/scripts/whatsapp-bridge/package-lock.json +@@ -10,6 +10,7 @@ + "dependencies": { + "@whiskeysockets/baileys": "7.0.0-rc13", + "express": "^4.21.0", ++ "https-proxy-agent": "7.0.6", + "pino": "^9.0.0", + "qrcode-terminal": "^0.12.0" + } +@@ -806,6 +807,15 @@ + "node": ">= 0.6" + } + }, ++ "node_modules/agent-base": { ++ "version": "7.1.4", ++ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", ++ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", ++ "license": "MIT", ++ "engines": { ++ "node": ">= 14" ++ } ++ }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", +@@ -1285,6 +1295,42 @@ + "url": "https://opencollective.com/express" + } + }, ++ "node_modules/https-proxy-agent": { ++ "version": "7.0.6", ++ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", ++ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", ++ "license": "MIT", ++ "dependencies": { ++ "agent-base": "^7.1.2", ++ "debug": "4" ++ }, ++ "engines": { ++ "node": ">= 14" ++ } ++ }, ++ "node_modules/https-proxy-agent/node_modules/debug": { ++ "version": "4.4.3", ++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", ++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", ++ "license": "MIT", ++ "dependencies": { ++ "ms": "^2.1.3" ++ }, ++ "engines": { ++ "node": ">=6" ++ }, ++ "peerDependenciesMeta": { ++ "supports-color": { ++ "optional": true ++ } ++ } ++ }, ++ "node_modules/https-proxy-agent/node_modules/ms": { ++ "version": "2.1.3", ++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", ++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", ++ "license": "MIT" ++ }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", +diff --git a/scripts/whatsapp-bridge/package.json b/scripts/whatsapp-bridge/package.json +index c6c3a525..0f12199e 100644 +--- a/scripts/whatsapp-bridge/package.json ++++ b/scripts/whatsapp-bridge/package.json +@@ -10,6 +10,7 @@ + "dependencies": { + "@whiskeysockets/baileys": "7.0.0-rc13", + "express": "^4.21.0", ++ "https-proxy-agent": "7.0.6", + "qrcode-terminal": "^0.12.0", + "pino": "^9.0.0" + }, diff --git a/scripts/whatsapp-bridge/proxy-agent.test.mjs b/scripts/whatsapp-bridge/proxy-agent.test.mjs new file mode 100644 -index 000000000..4b489e7e0 --- /dev/null +++ b/scripts/whatsapp-bridge/proxy-agent.test.mjs @@ -0,0 +1,92 @@ @@ -147,86 +227,3 @@ index 000000000..4b489e7e0 + assert.equal(options.agent, undefined); + assert.equal(options.fetchAgent, undefined); +}); -diff --git a/scripts/whatsapp-bridge/package-lock.json b/scripts/whatsapp-bridge/package-lock.json -index b3043d288..06550adcc 100644 ---- a/scripts/whatsapp-bridge/package-lock.json -+++ b/scripts/whatsapp-bridge/package-lock.json -@@ -10,6 +10,7 @@ - "dependencies": { - "@whiskeysockets/baileys": "7.0.0-rc13", - "express": "^4.21.0", -+ "https-proxy-agent": "7.0.6", - "pino": "^9.0.0", - "qrcode-terminal": "^0.12.0" - } -@@ -806,6 +807,15 @@ - "node": ">= 0.6" - } - }, -+ "node_modules/agent-base": { -+ "version": "7.1.4", -+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", -+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", -+ "license": "MIT", -+ "engines": { -+ "node": ">= 14" -+ } -+ }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", -@@ -1285,6 +1295,42 @@ - "url": "https://opencollective.com/express" - } - }, -+ "node_modules/https-proxy-agent": { -+ "version": "7.0.6", -+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", -+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", -+ "license": "MIT", -+ "dependencies": { -+ "agent-base": "^7.1.2", -+ "debug": "4" -+ }, -+ "engines": { -+ "node": ">= 14" -+ } -+ }, -+ "node_modules/https-proxy-agent/node_modules/debug": { -+ "version": "4.4.3", -+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", -+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", -+ "license": "MIT", -+ "dependencies": { -+ "ms": "^2.1.3" -+ }, -+ "engines": { -+ "node": ">=6.0" -+ }, -+ "peerDependenciesMeta": { -+ "supports-color": { -+ "optional": true -+ } -+ } -+ }, -+ "node_modules/https-proxy-agent/node_modules/ms": { -+ "version": "2.1.3", -+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", -+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", -+ "license": "MIT" -+ }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", -diff --git a/scripts/whatsapp-bridge/package.json b/scripts/whatsapp-bridge/package.json -index 3b664108b..1a45debb7 100644 ---- a/scripts/whatsapp-bridge/package.json -+++ b/scripts/whatsapp-bridge/package.json -@@ -10,6 +10,7 @@ - "dependencies": { - "@whiskeysockets/baileys": "7.0.0-rc13", - "express": "^4.21.0", -+ "https-proxy-agent": "7.0.6", - "qrcode-terminal": "^0.12.0", - "pino": "^9.0.0" - }, diff --git a/agents/openclaw/wechat-runtime/package-lock.json b/agents/openclaw/wechat-runtime/package-lock.json index edcabbfb93a..218ea874df0 100644 --- a/agents/openclaw/wechat-runtime/package-lock.json +++ b/agents/openclaw/wechat-runtime/package-lock.json @@ -29,6 +29,11 @@ }, "peerDependencies": { "openclaw": ">=2026.3.22" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } } }, "node_modules/qrcode-terminal": { diff --git a/ci/pi-agent-qualification-v1-linux-amd64.json b/ci/pi-agent-qualification-v1-linux-amd64.json index 067ec5e2783..f0f2082230c 100644 --- a/ci/pi-agent-qualification-v1-linux-amd64.json +++ b/ci/pi-agent-qualification-v1-linux-amd64.json @@ -3,13 +3,13 @@ "agent": "pi", "platform": "linux/amd64", "image": "ghcr.io/nvidia/nemoclaw/pi-sandbox", - "digest": "sha256:3f8c1fe3e9103489c11bb16c2d984d201be01440d66db87ec0ce9390ad0de7e0", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:3f8c1fe3e9103489c11bb16c2d984d201be01440d66db87ec0ce9390ad0de7e0", + "digest": "sha256:7e95430e4ef79951289a0db4a3a0c18e19a3022731da7461e1f6968fd6fde737", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:7e95430e4ef79951289a0db4a3a0c18e19a3022731da7461e1f6968fd6fde737", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "f4249cd772bd099e0210c0851522080d85fbd30b", + "revision": "8489f2bfa61756a0b9e923c4e6a10a254e19f410", "release": "v0.1.0", - "cohort": "ghrun-33731127761-1" + "cohort": "ghrun-33803401296-2" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/ci/pi-agent-qualification-v1-linux-arm64.json b/ci/pi-agent-qualification-v1-linux-arm64.json index 9ea1a767356..962f1bcd15f 100644 --- a/ci/pi-agent-qualification-v1-linux-arm64.json +++ b/ci/pi-agent-qualification-v1-linux-arm64.json @@ -3,13 +3,13 @@ "agent": "pi", "platform": "linux/arm64", "image": "ghcr.io/nvidia/nemoclaw/pi-sandbox", - "digest": "sha256:f3553f59c09f4d40c20f4d93b3db0a43e87835373d2de02968292f6bfc72af07", - "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:f3553f59c09f4d40c20f4d93b3db0a43e87835373d2de02968292f6bfc72af07", + "digest": "sha256:904e897b2f798fd4ee83f9a96083d6f00624d7c4c58b6facfc39b0b5bbefe5f4", + "reference": "ghcr.io/nvidia/nemoclaw/pi-sandbox@sha256:904e897b2f798fd4ee83f9a96083d6f00624d7c4c58b6facfc39b0b5bbefe5f4", "source": { "repository": "NVIDIA/NemoClaw", - "revision": "f4249cd772bd099e0210c0851522080d85fbd30b", + "revision": "8489f2bfa61756a0b9e923c4e6a10a254e19f410", "release": "v0.1.0", - "cohort": "ghrun-33731127761-1" + "cohort": "ghrun-33803401296-2" }, "startupProfileContractVersion": 1, "capabilityContractVersion": 1 diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index c9bcc15a4b6..20ec4ea12f8 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -114,7 +114,7 @@ "integrity": "sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==", "tarballUrl": "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", "directory": "agents/openclaw/wechat-runtime", - "lockSha256": "27fcc4abe6707d5d710c691b25b942a269ebe2b652c6591174ee01fe06a5df35", + "lockSha256": "09a91cabd559ed2294fb263602009f9f79259e765281992e56961eed0e8c1ed9", "severityThreshold": "low", "installMode": "legacy-peer-deps", "inputValidation": "wechat-runtime", diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index f2172794001..f2dd445fe7f 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -86,11 +86,6 @@ "test": "binds trusted base publication to the generic NVIDIA GPU job", "category": "security" }, - { - "file": "test/agents/hermes/hermes-final-image-layout.test.ts", - "test": "pins $source to its current bytes at $target", - "category": "security" - }, { "file": "test/platform/images/muse-glimmer-vllm-image-provenance.test.ts", "test": "binds the checked-in provenance to the selected runtime", @@ -216,6 +211,11 @@ "test": "rejects malformed reviewed source package specifications", "category": "security" }, + { + "file": "test/agents/hermes/hermes-image-build-probes.test.ts", + "test": "binds the runtime environment validator to its source digest", + "category": "security" + }, { "file": "test/agents/hermes/reviewed-hermes-platform-action.test.ts", "test": "publishes the verified native manifest digest", diff --git a/docs/manage-sandboxes/install-plugins-hermes.mdx b/docs/manage-sandboxes/install-plugins-hermes.mdx index b62a32b2911..3176396012a 100644 --- a/docs/manage-sandboxes/install-plugins-hermes.mdx +++ b/docs/manage-sandboxes/install-plugins-hermes.mdx @@ -36,6 +36,8 @@ Instead, gateway startup installs the bundled Hindsight dependency into a gatewa Neither target modifies the root virtual environment, and neither target is declared in the generated `.env` file. NemoClaw supports `hindsight-client==0.6.1` for this workflow. +NemoClaw accepts only the reviewed wheel hashes for `hindsight-client==0.6.1` and `aiohttp-retry==2.9.1`. +Setup or gateway startup stops if an index or mirror serves a different artifact under either version. Hermes checks this version before it loads the plugin. This version uses the OpenShell proxy path. Do not replace it with a `0.8.x` client because those releases do not use the proxy environment. diff --git a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx index 161bdb13ea9..f40491c6c6a 100644 --- a/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx +++ b/docs/manage-sandboxes/recover-rebuild-sandboxes.mdx @@ -247,6 +247,11 @@ $$nemoclaw rebuild On WSL with Docker Desktop, a generated replacement image build uses a temporary credential-free Docker configuration when the configured Docker Desktop credential helper is unavailable. NemoClaw removes the temporary configuration after the build and does not modify your Docker configuration. An explicit custom Dockerfile continues to use your configured Docker credentials because its base image or build steps might require a private registry. If that custom rebuild cannot reach the credential helper, restore the Docker Desktop session or credential-helper access before retrying. +Rebuild creates the replacement from the base image selected during preflight, even when the source sandbox records an older base-image hint. +When the target manifest declares an agent version, NemoClaw runs a fresh live version probe after state restoration. +It records the replacement version and reports success only when the observed version matches the rebuild target. +An unavailable or different version ends the rebuild with a nonzero status instead of recording the target version as observed. + After post-restore writes, rebuild verifies that the final `openclaw.json` and `.config-hash` pair match. @@ -298,15 +303,50 @@ If that required provider is missing or incompatible, restore or re-add the chan A rebuild creates a new sandbox home and a new Hermes API bearer token. After the rebuild succeeds, retrieve the replacement token before reconnecting API clients: + +The command prints the replacement Hermes API bearer token to standard output. +The token remains valid while the sandbox exists and its `API_SERVER_KEY` stays unchanged and valid. +A rebuild replaces the token. +Gateway startup also generates a new token when `API_SERVER_KEY` is missing or is not exactly 64 lowercase hexadecimal characters. +If API authentication fails after a gateway restart, retrieve the current token again with `nemohermes gateway-token --quiet`. +Do not log, share, or commit the token. + + ```bash -$$nemoclaw gateway-token --quiet +nemohermes gateway-token --quiet ``` Before post-restore repairs, NemoClaw verifies that the recreated sandbox still identifies as Hermes and exits nonzero if its identity does not match the rebuild target. After state restore, NemoClaw restarts the Hermes gateway so it reads the restored durable state, then restores managed MCP configuration through the normal lifecycle. MCP restoration performs an acknowledged gateway reload, so NemoClaw finishes by verifying the final running gateway and its managed MCP state without replacing that verified process again. The gateway starts during recreation and reads its durable state before the restore replaces it, which is why the first post-restore restart must happen before managed MCP restoration. `rebuild` exits nonzero instead of reporting success when it cannot verify final gateway health or managed MCP state. Follow the printed recovery guidance, using `$$nemoclaw gateway restart` first for gateway health, `$$nemoclaw recover` when the restart does not restore verified health, and `$$nemoclaw mcp restart` for incomplete managed MCP restoration. When the rebuild backup contains active Hermes cron jobs that reference scripts, NemoClaw validates those script references before it deletes the existing sandbox. The check covers the default profile and named profiles. Each referenced script must exist, be readable, be a regular file, and remain inside its profile's `scripts` directory. Disabled and paused jobs do not require their referenced scripts. If this validation fails, the rebuild keeps the existing sandbox and reports the preserved backup path. -After NemoClaw creates the replacement, it acquires an independent root-owned gate that blocks new Hermes turns and cron dispatch. The gate remains active across gateway and container restarts in the replacement sandbox. NemoClaw waits for active agent work to finish before restoring state. It validates the restored jobs and scripts before the gateway replacement, then keeps dispatch blocked while it restarts and verifies that replacement. It records the replacement process identity around managed health verification and clears the gate only if that same live process completes the final cron validation. If an operator already drained the gateway, NemoClaw clears its gate and release recovery record while leaving the operator drain active. If state restore, managed MCP restoration, gateway replacement, or cron validation fails after gate acquisition, the command exits nonzero and preserves the backup. Those failures retain the NemoClaw gate unless the output explicitly reports that release rollback could not restore its marker. In that exceptional case, NemoClaw preserves a root-owned release recovery record, but you must not assume dispatch is blocked. Run `$$nemoclaw recover` immediately so it can reacquire the gate before validating the restored cron state. If gate reacquisition fails, recovery exits nonzero and leaves the recovery record in place for another attempt. Failures before gate acquisition do not create a new gate. Do not manually remove the root-owned cron restore marker or release recovery record because removal bypasses restored cron validation. If managed MCP restoration failed, correct the reported cause and run `$$nemoclaw mcp restart` first. Then run `$$nemoclaw recover` to repair and probe the gateway, validate the restored cron tree, and clear NemoClaw-owned cron restore recovery state. +After NemoClaw creates the replacement, it acquires an independent root-owned gate that blocks new Hermes turns and cron dispatch. +The gate remains active across gateway and container restarts in the replacement sandbox. +NemoClaw waits for active agent work to finish before restoring state. +It validates the restored jobs and scripts before the gateway replacement. +NemoClaw keeps dispatch blocked while it restarts and verifies that replacement. +It records the replacement process identity around managed health verification. +NemoClaw clears the gate only if that same live process completes the final cron validation. +If an operator already drained the gateway, NemoClaw clears its gate and release recovery record while leaving the operator drain active. + +If state restore, managed MCP restoration, gateway replacement, agent-version verification, or cron validation fails after gate acquisition, `rebuild` exits nonzero and preserves the backup. +`rebuild` keeps the NemoClaw gate active after these failures unless release rollback cannot restore the drain marker. +Failures before gate acquisition do not create a new gate. + + +If the output includes `Hermes cron restore drain release failed and its marker could not be restored`, cron dispatch state is unverified. +Do not assume that new Hermes turns or cron dispatch remain blocked. +NemoClaw preserves the root-owned release recovery record in this state. +Run `$$nemoclaw recover` immediately. +`recover` uses that record to reacquire the gate before it validates the restored cron state. +If gate reacquisition fails, `recover` exits nonzero and leaves the recovery record for another attempt. +Do not manually remove the cron restore marker or release recovery record because removal bypasses restored cron validation. + + +If managed MCP restoration failed, correct the reported cause and run `$$nemoclaw mcp restart` first. +Then run `$$nemoclaw recover` to repair the gateway, validate the restored cron tree, and clear NemoClaw-owned recovery state. + +When NemoClaw is ready to reopen dispatch and no operator drain remains, its root-owned controller reschedules only enabled one-shot jobs that became due at or after the NemoClaw gate was acquired, have never run, and carry no dispatch or fire claim. It persists that update before removing the gate. If the update fails, rebuild or recovery exits nonzero and leaves the gate active. diff --git a/docs/reference/headless-lifecycle-package.mdx b/docs/reference/headless-lifecycle-package.mdx index 3dee83fb981..8e0017bb957 100644 --- a/docs/reference/headless-lifecycle-package.mdx +++ b/docs/reference/headless-lifecycle-package.mdx @@ -12,7 +12,7 @@ content: --- Use `nemoclaw/lifecycle` when a service needs deterministic Hermes planning and read-only observation without invoking the NemoClaw command-line interface (CLI). -The first API version supports Hermes `0.19.0` with OpenShell `0.0.106`. +The first API version supports Hermes `0.20.6` with OpenShell `0.0.106`. ## Understand the Boundary diff --git a/internal/security-reviews/hermes-0.20.6-dependency-review.md b/internal/security-reviews/hermes-0.20.6-dependency-review.md new file mode 100644 index 00000000000..179f61fac3e --- /dev/null +++ b/internal/security-reviews/hermes-0.20.6-dependency-review.md @@ -0,0 +1,178 @@ + + + +# Hermes 0.20.6 dependency and compatibility review + +> Internal engineering evidence. This file is not part of the public documentation set. + +Review date: August 29, 2026. + +## Decision + +Update the NVIDIA/NemoClaw Hermes runtime from `v2026.7.20` / `0.19.0` to +the published `v2026.8.27` / `0.20.6` release. The target source is +`5fc308a70719a83cccdbba4c0e39c23f5a8239d5`. + +This change can remain a draft while the required evidence is incomplete. +Before qualification, obtain `CI / Pull Request` evidence and the authenticated +E2E job set selected by `.github/workflows/e2e.yaml`. The Mac authoring checks do +not qualify the Linux image or sandbox lifecycle. The `staging-brev-launchable` +job is supplemental evidence and does not replace the selected job set. + +## Reviewed identities + +| Identity | Value | +| --- | --- | +| Current release | `v2026.7.20` / `0.19.0` | +| Current source | `3ef6bbd201263d354fd83ec55b3c306ded2eb72a` | +| Target release | `v2026.8.27` / `0.20.6` | +| Target source | `5fc308a70719a83cccdbba4c0e39c23f5a8239d5` | +| Target source archive SHA-256 | `e622723b5bf3cd6c1db974d92d32242f1cb63f61c1112b6f708b34d619ef0fc7` | +| Target npm integrity cross-check | `sha512-s5q1IEBifCBb77QMwkse4MRaAaoZSxIa4IkicIO3jL7MIdq15YvnSyiNvsTOWNBi6t3shFpIg+H7+9MJsOiSkg==` | +| Target base image index | `ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@sha256:c588bf76ba1c280f8a366bdfd672193e852de4f509a280662c7070a9b6e2fa48` | +| Base image publication | NVIDIA/NemoClaw run `33694485635`, source revision `d7855f99095861db4d14dd9f831a3827d5122a2f` | +| Base image amd64 digest | `sha256:b6a7359adaa4694d7321b785bcb86afa5d29315b1ed601be68d29f7da494ed50` | +| Base image arm64 digest | `sha256:1d7b4d64d1f4cb7d20923f31ab3fe7e3631bfbeaf5b8e7c3473d138894e01eb1` | +| Base image contract SHA-256 | `23de5d6fa2eaaa78a59ddfc91af5b2655f2f07d02047d49d6fb39e04308ee76f` | +| NemoPin comparison base | `4e0e663a9a4cf6bac8df8972ea23dfc26ce3c309` | +| NVIDIA/NemoClaw authoring base | `b12bede8bfa5bc7a8c083f54fc79a4f5663b81df` | +| NemoPin handoff manifest | `sha256:ec3f152824a843b9970aa8342de0ad15289d99899af06e6eb94baec8e29e5744` | + +The source archive and package cross-check remain separate inputs. The image +build consumes the checksum-pinned source archive. The npm value detects a +release-identity mismatch; it is not the source used to assemble the image. +The trusted base-image workflow published and verified the listed Linux amd64 +and arm64 manifest before the final Hermes image selected it. + +## Semantic migration + +Hermes 0.20.6 moves the default configuration dictionary into +`hermes_cli/config_defaults.py`, the TUI configuration response into +`tui_gateway/methods_config.py`, and update behavior into +`hermes_cli/update_cmd.py`. The profile-policy patch now binds those exact +files. It preserves NVIDIA/NemoClaw's manual approvals, restricted browser +evaluation, hidden reasoning and commentary, bounded session reset, disabled +in-place backup, and disabled CUA refresh for a config-less profile. + +The cron execution ledger now resolves its default path at use time. The +compatibility patch preserves that behavior while redirecting the default +ledger to `runtime/cron-executions.db`. The online backup inventory remains +bound to the same runtime file. + +The upstream scheduler also writes its per-profile tick lock below `cron/`. +NVIDIA/NemoClaw Shields seals that directory after configuration, so the +source patch moves only `.tick.lock` to writable `runtime/` state. Cron +definitions remain read-only while concurrent scheduler ticks retain the +upstream file-lock behavior. + +The session-list implementation contains five exact preview queries in the +target source. The patch remains necessary and changes each preview from the +first message to the latest message. It also makes the workspace-aware table +show that latest preview in place of a derived or model-generated seed title, +while a user-authored title remains authoritative. Both source shapes are +exact-count guarded. + +The SQLite helper now calls `apply_database_pragmas` before enabling foreign +keys. The temp-store patch was retargeted to that exact target shape. The cron +restore drain gained additional upstream constants, so its marker insertion +anchor moved to the exact drain-request filename declaration. During an +authenticated NVIDIA/NemoClaw release, the root-owned controller re-arms only +enabled, scheduled, unclaimed one-shots that became due at or after gate +acquisition while the drain was active. The root-owned release recovery record +retains that original acquisition time when it must recreate the gate. That +durable update completes before dispatch resumes; a failed update keeps the +gate closed. + +The target still contains direct credential-driven platform activation paths. +The neutral-platform patch therefore remains necessary. It captures every +explicitly disabled platform before environment processing and restores those +complete objects afterward. The target import block includes `math`, and the +exact patch anchor and output digests were updated accordingly. + +`/proc/1/environ` records the environment from PID 1 startup and does not +reflect later launcher exports. For the Hermes boundary, the trusted launcher +overwrites only `HERMES_HOME`, `HERMES_LAZY_INSTALL_TARGET`, and +`HERMES_BUNDLED_PLUGINS` before `hermes gateway run`. The root managed +controller applies those values to the captured environment and validates the +effective environment in process. It still fails closed for prohibited runtime +controls, OpenShell supervisor-only variables, and raw secret-shaped values. +`test/agents/hermes/hermes-env-secret-boundary-hardening.test.ts` and +`test/inference/managed/managed-gateway-control.test.ts` cover the stale +`/proc` snapshot, launcher overrides, in-process validation, and rejection +cases. + +The gateway-runtime-metadata, gateway-process-identity, Discord recovery, +Langfuse credential, provider/model translation, resumed one-shot, and cron +restore controls still apply to the reviewed target shapes. Each retained +compatibility path remains guarded by source, patcher, output, parser, or image +probe evidence. Removal requires an upstream behavior change and a matching +NVIDIA/NemoClaw regression. + +## Dependency closure + +Hermes 0.20.6 already carries the security floors that NVIDIA/NemoClaw +previously overlaid into the 0.19.0 lock. The selected frozen graph contains: + +- `agent-client-protocol==0.9.0` +- `aiohttp==3.14.3` +- `cryptography==50.0.0` +- `mcp==2.0.0` +- `Pillow==12.3.0` +- `python-multipart==0.0.32` +- `starlette==1.3.1` +- `tornado==6.5.7` + +The base build verifies the selected installed versions with package metadata +and runs `uv pip check`. The existing hash-locked multipart and Hindsight +compatibility probes remain unchanged. + +The managed Microsoft Teams capability union also carries the reviewed +`pydantic-settings==2.14.2` wheel because the selected base does not contain +that required dependency. Its SHA-256 is +`a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440`. +The offline union install must succeed before its package-version probe runs. + +The broad 0.19.0 dependency patch is no longer carried forward. Hermes 0.20.6 +already routes memory-provider installation through `tools.lazy_deps.install_specs` +and already contains the selected security versions. The source patch changes +the Hindsight plugin declaration from `hindsight-client>=0.6.1` to +`hindsight-client==0.6.1`. This keeps runtime resolution aligned with the +separately hash-verified offline 0.6.1 probe. + +Hermes 0.20.6 resolves agent-browser through an npx fallback instead of the +root dependency graph. The source patch changes `agent-browser@^0.26.0` to +`agent-browser@0.26.0`. A reviewed npm lockfile binds the package archive and +integrity. The base image installs the locked package into root-owned +`/opt/nemoclaw-agent-browser-runtime`, removes sandbox-user write access, and +links `/usr/local/bin/agent-browser` to the installed executable. The same image +layer removes the root npm cache and temporary npm configuration files. A +network-disabled probe runs the link as the sandbox user, verifies the exact +version and link target, and confirms that the sandbox user cannot modify the +executable or delete the link. Hermes must then resolve +`/usr/local/bin/agent-browser`. The final image inherits this boundary and keeps +the npx fallback in npm offline mode. + +Hermes 0.20.6 also added a one-shot completion linger that waits on every +tracked process in the CLI registry. A managed sandbox can contain unrelated +background processes, so a completed `hermes -z` turn can remain open until the +upstream 600-second bound expires. The source patch assigns one fresh task ID to +the one-shot turn and passes that same ID to the completion wait. Background +work created by the turn still receives its bounded linger, while unrelated +managed-runtime processes cannot delay the final response. + +No new supported integration is introduced. Optional upstream features remain +subject to their existing NVIDIA/NemoClaw product and policy gates. + +## Validation boundary + +The Mac authoring checks prove exact source selection, patch applicability, +contract updates, and deterministic repository changes. They do not prove the +Linux image, OpenShell sandbox, upgrade recovery, or canonical end-to-end path. +The trusted manual PR plan is defined by `.github/workflows/e2e.yaml` and +`tools/e2e/target-catalogue.mts`. Its authenticated result is the authoritative +required-job set; this review does not maintain a second partial list. Brev may +produce background shadow evidence, but it is nonqualifying and cannot replace +the trusted plan or a supported scenario receipt. + +Unresolved upgrade-created high-impact concerns: `0` in the authored source +diff. Qualification remains pending the required authenticated CI evidence. diff --git a/nemoclaw-blueprint/policies/presets/nous-browser.yaml b/nemoclaw-blueprint/policies/presets/nous-browser.yaml index 5b32aae26b4..213b4bf6fc3 100644 --- a/nemoclaw-blueprint/policies/presets/nous-browser.yaml +++ b/nemoclaw-blueprint/policies/presets/nous-browser.yaml @@ -109,15 +109,13 @@ network_policies: - allow: { method: GET, path: "/devtools/**" } binaries: - { path: /usr/local/bin/hermes } + - { path: /usr/local/bin/agent-browser* } - { path: /usr/local/bin/node* } - { path: /usr/local/bin/npx* } - { path: /usr/bin/node* } - { path: /usr/bin/npx* } - { path: /opt/hermes/node_modules/.bin/agent-browser* } - { path: /opt/hermes/node_modules/.bin/playwright* } - - { path: /sandbox/.hermes-data/node/bin/node* } - - { path: /sandbox/.hermes-data/node/bin/npx* } - - { path: /sandbox/.hermes-data/node/bin/agent-browser* } - { path: /sandbox/.hermes/node/bin/node* } - { path: /sandbox/.hermes/node/bin/npx* } - { path: /sandbox/.hermes/node/bin/agent-browser* } diff --git a/scripts/checks/pull-public-exact-digest.sh b/scripts/checks/pull-public-exact-digest.sh index f9fcbebe33e..c0d9aed043f 100755 --- a/scripts/checks/pull-public-exact-digest.sh +++ b/scripts/checks/pull-public-exact-digest.sh @@ -88,26 +88,30 @@ for ((attempt = 1; attempt <= max_attempts; attempt += 1)); do status="$?" fi - if [ "$status" -ne 1 ]; then - echo "::error::GHCR anonymous exact-digest pull outcome=failed-no-retry attempt=$attempt/$max_attempts docker-exit=$status" >&2 - exit "$status" - fi + last_line="$(awk 'NF { line=$0 } END { sub(/\r$/, "", line); print line }' "$attempt_log")" if grep -Fq "max depth exceeded" "$attempt_log"; then echo "::error::GHCR anonymous exact-digest pull outcome=failed-no-retry attempt=$attempt/$max_attempts docker-exit=$status failure=layer-depth-exceeded" >&2 exit "$status" fi retryable_visibility=0 - if grep -Eq '^Error response from daemon: Head .+: denied$' "$attempt_log" \ + if [[ "$last_line" == *"$reference: not found" ]]; then + retryable_visibility=1 + elif [ "$status" -eq 1 ] && { grep -Eq '^Error response from daemon: Head .+: denied$' "$attempt_log" \ || grep -Fxq 'denied: permission_denied' "$attempt_log" \ || grep -Fxq "ERROR: $reference: not found" "$attempt_log" \ || grep -Eq '^(Error response from daemon: failed to resolve reference "ghcr[.]io/[^"]+": )?unexpected status from HEAD request to https://ghcr[.]io/.+: (401 Unauthorized|403 Forbidden|404 Not Found)$' "$attempt_log" \ || grep -Eiq '(^|:[[:space:]]|[[:space:]])manifest unknown(:|[[:space:]]|$)' "$attempt_log" \ || grep -Fq 'unexpected status from anonymous HEAD request' "$attempt_log" \ - || grep -Fq 'failed to resolve exact digest from anonymous GHCR' "$attempt_log"; then + || grep -Fq 'failed to resolve exact digest from anonymous GHCR' "$attempt_log"; }; then retryable_visibility=1 fi if [ "$retryable_visibility" -ne 1 ]; then - echo "::error::GHCR anonymous exact-digest pull outcome=failed-no-retry attempt=$attempt/$max_attempts docker-exit=$status failure=terminal-docker-exit-1" >&2 + if [ "$status" -eq 1 ]; then + failure=" failure=terminal-docker-exit-1" + else + failure="" + fi + echo "::error::GHCR anonymous exact-digest pull outcome=failed-no-retry attempt=$attempt/$max_attempts docker-exit=$status${failure}" >&2 exit "$status" fi diff --git a/scripts/lib/seed-reviewed-npm-cache.mts b/scripts/lib/seed-reviewed-npm-cache.mts index b9e9de690d1..2afe5eb8e63 100755 --- a/scripts/lib/seed-reviewed-npm-cache.mts +++ b/scripts/lib/seed-reviewed-npm-cache.mts @@ -55,7 +55,7 @@ type LockedPackage = Readonly<{ }>; const INSTALL_ACCEPT = "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*"; -const REVIEWED_CI_NPM_VERSIONS = new Set(["10.9.4", "10.9.8", "11.17.0"]); +const REVIEWED_CI_NPM_VERSIONS = new Set(["10.9.4", "10.9.8", "11.17.0", "11.18.0"]); function packageNameFromLockLocation(location: string): string { const marker = "node_modules/"; @@ -208,7 +208,7 @@ function loadCachePut(): CachePut { const npmVersion = execFileSync("npm", ["--version"], { encoding: "utf8" }).trim(); if (!REVIEWED_CI_NPM_VERSIONS.has(npmVersion)) { throw new Error( - `reviewed npm cache seed does not support npm@${npmVersion}; expected npm@10.9.4, npm@10.9.8, or npm@11.17.0`, + `reviewed npm cache seed does not support npm@${npmVersion}; expected npm@10.9.4, npm@10.9.8, npm@11.17.0, or npm@11.18.0`, ); } const npmRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); diff --git a/scripts/managed-gateway-control.py b/scripts/managed-gateway-control.py index 640dc2b7b81..b9565cd197d 100755 --- a/scripts/managed-gateway-control.py +++ b/scripts/managed-gateway-control.py @@ -1589,8 +1589,10 @@ def _run_fixed_validator( raise ControlError("SECRET_BOUNDARY_REFUSED") -def _validate_runtime_environment(script: str, environment: dict[str, str]) -> None: - """Validate runtime values without execing a root process under them.""" +def _validate_managed_gateway_environment( + script: str, supervisor_environment: dict[str, str] +) -> None: + """Validate the Hermes gateway environment without executing under untrusted input.""" _validate_trusted_regular(script) spec = importlib.util.spec_from_file_location( @@ -1601,8 +1603,8 @@ def _validate_runtime_environment(script: str, environment: dict[str, str]) -> N module = importlib.util.module_from_spec(spec) try: spec.loader.exec_module(module) - validator = getattr(module, "validate_runtime_env") - result = validator(environment) + validator = getattr(module, "validate_managed_gateway_env") + result = validator(supervisor_environment) except (AttributeError, ImportError, OSError, RuntimeError) as exc: raise ControlError("SECRET_BOUNDARY_REFUSED") from exc if result != 0: @@ -1722,7 +1724,7 @@ def _hermes_preflight( MAX_ENV_BYTES, recovery_deadline, ) - _validate_runtime_environment(validator, _parse_environment(raw_environment)) + _validate_managed_gateway_environment(validator, _parse_environment(raw_environment)) _require_recovery_time(recovery_deadline) _verify_locked_hermes_hash() _require_recovery_time(recovery_deadline) diff --git a/src/lib/actions/lifecycle/observe-hermes.test.ts b/src/lib/actions/lifecycle/observe-hermes.test.ts index 83a89dbd8f7..f19b1931177 100644 --- a/src/lib/actions/lifecycle/observe-hermes.test.ts +++ b/src/lib/actions/lifecycle/observe-hermes.test.ts @@ -57,7 +57,7 @@ function observed( }, agent: { name: "hermes", - version: "0.19.0", + version: "0.20.6", configurationFingerprint: CONFIGURATION_FINGERPRINT, health, }, @@ -94,7 +94,7 @@ describe("Hermes lifecycle observation", () => { value: { apiVersion: "v1alpha1", state: "present", - agent: { name: "hermes", version: "0.19.0", readiness: "ready" }, + agent: { name: "hermes", version: "0.20.6", readiness: "ready" }, target: planRequest().target, sandbox: { ...planRequest().sandbox, diff --git a/src/lib/actions/sandbox/connect-probe-observe.test.ts b/src/lib/actions/sandbox/connect-probe-observe.test.ts index ee7e990cc48..e2b561eaa21 100644 --- a/src/lib/actions/sandbox/connect-probe-observe.test.ts +++ b/src/lib/actions/sandbox/connect-probe-observe.test.ts @@ -39,6 +39,16 @@ describe("connectSandbox probe-only observe mode", () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it("does not require portable authority for an ordinary Hermes probe-only recovery", async () => { + const harness = createConnectHarness({ agentName: "hermes" }); + + await expect(harness.connectSandbox("alpha", { probeOnly: true })).resolves.toBeUndefined(); + + expect(harness.inspectPortableReceiptDispositionSpy).toHaveBeenCalled(); + expect(harness.qualifyHermesPortableAcceptedReadinessAuthoritySpy).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + it("runs portable lifecycle recovery before the live sandbox lookup (#8441)", async () => { const harness = createConnectHarness(); diff --git a/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts b/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts index a90fcb6a915..593e0ca1695 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-accepted-target.test.ts @@ -193,7 +193,7 @@ describe("Hermes accepted replacement recovery", () => { vi.restoreAllMocks(); }); - it("recovers a stranded cron gate without a current gate plan before accepting the replacement (#7806)", async () => { + it("restores accepted replacement state before reopening cron dispatch (#7806)", async () => { const events: string[] = []; phaseMocks.recoverCronRestore.mockImplementation(() => { events.push("recover"); @@ -223,9 +223,9 @@ describe("Hermes accepted replacement recovery", () => { ).resolves.toBeUndefined(); expect(events).toEqual([ - "recover", "restore", "post-restore", + "recover", "retire-removed-immutability", "clear-recovery", "complete", diff --git a/src/lib/actions/sandbox/rebuild-hermes-cron-restore.test.ts b/src/lib/actions/sandbox/rebuild-hermes-cron-restore.test.ts index a9164704e02..057035fd1fe 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-cron-restore.test.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-cron-restore.test.ts @@ -75,6 +75,7 @@ function receipt( operator_drain_active: false, preserved_drain: false, profiles: 1, + rearmed_oneshots: 1, script_jobs: 1, }, recover: { @@ -84,6 +85,7 @@ function receipt( operator_drain_active: false, preserved_drain: false, profiles: 1, + rearmed_oneshots: 1, script_jobs: 1, }, }; @@ -438,6 +440,31 @@ describe("Hermes cron rebuild restore contract", () => { ).toThrow("receipt failed validation"); }); + it.each([ + ["missing", undefined], + ["negative", -1], + ["fractional", 0.5], + ])("rejects a %s rearmed one-shot count", (_label, rearmedOneshots) => { + const overrides = + rearmedOneshots === undefined + ? { rearmed_oneshots: undefined } + : { rearmed_oneshots: rearmedOneshots }; + const stdout = receipt("complete", 77, 903, "restore-token", overrides); + processMocks.executePrivilegedSandboxCommand.mockReturnValue({ + status: 0, + stdout, + stderr: "", + }); + + expect(() => + completeHermesCronRestoreAfterGatewayReplacement( + "alpha", + { pid: 41, start_time: 902, drain_token: "restore-token" }, + { pid: 77, start_time: 903, drain_token: "restore-token" }, + ), + ).toThrow("receipt failed validation"); + }); + it("observes the replacement identity without releasing the held gate (#8472)", () => { processMocks.executePrivilegedSandboxCommand.mockReturnValue({ status: 0, @@ -577,6 +604,16 @@ describe("Hermes cron rebuild restore contract", () => { expect(recoverHermesCronRestore("alpha")).toBe("not-required"); }); + it("rejects a rearmed one-shot count when no recovery gate exists", () => { + processMocks.executePrivilegedSandboxCommand.mockReturnValue({ + status: 0, + stdout: notRequiredRecoveryReceipt({ rearmed_oneshots: 0 }), + stderr: "", + }); + + expect(() => recoverHermesCronRestore("alpha")).toThrow("receipt failed validation"); + }); + it("accepts not-required while preserving an independent operator drain", () => { processMocks.executePrivilegedSandboxCommand.mockReturnValue({ status: 0, diff --git a/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts b/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts index 9d785597af7..7fe32822b58 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-post-restore.test.ts @@ -7,8 +7,6 @@ import { installRebuildFlowTestHooks, } from "../../../../test/helpers/rebuild-flow-generic-harness"; import { - ensureHermesGatewayAfterStateRestore, - ensureHermesGatewayAfterStateRestoreForCronGate, restartHermesGatewayAfterStateRestore, verifyHermesGatewayAfterStateRestore, verifyHermesGatewayAfterStateRestoreForCronGate, @@ -39,51 +37,6 @@ const MCP_REFUSED_BEFORE_RESTART = { } as const; describe("binding the Hermes gateway to restored state", () => { - it("restarts the gateway before reading its health (#8184)", () => { - const order: string[] = []; - const state = ensureHermesGatewayAfterStateRestore("alpha", "hermes", { - restartSandboxGateway: () => { - order.push("restart"); - return RESTART_SUCCEEDED; - }, - checkAndRecoverSandboxProcesses: () => { - order.push("check"); - return { checked: true, wasRunning: true, recovered: false }; - }, - }); - - expect(state).toBe("healthy"); - expect(order).toEqual(["restart", "check"]); - }); - - // The bug this replaces: the gateway read its durable state at startup, the - // restore replaced that state afterwards, and a live process satisfied the - // old liveness check while still serving what it read before the restore. - it("refuses a gateway that stayed up through a failed restart (#8184)", () => { - const state = ensureHermesGatewayAfterStateRestore("alpha", "hermes", { - restartSandboxGateway: () => RESTART_FAILED, - checkAndRecoverSandboxProcesses: () => ({ - checked: true, - wasRunning: true, - recovered: false, - }), - }); - - expect(state).toBe("unverified"); - }); - - it("accepts a gateway the recovery check replaced after a failed restart (#8184)", () => { - const state = ensureHermesGatewayAfterStateRestore("alpha", "hermes", { - restartSandboxGateway: () => RESTART_FAILED, - checkAndRecoverSandboxProcesses: () => ({ - checked: true, - wasRunning: false, - recovered: true, - }), - }); - - expect(state).toBe("recovered"); - }); it("keeps restart evidence while rebuild restores the managed MCP projection (#8671)", () => { const restartState = restartHermesGatewayAfterStateRestore("alpha", "hermes", { @@ -136,99 +89,7 @@ describe("binding the Hermes gateway to restored state", () => { ).toBe("unverified"); }); - it("leaves a non-Hermes rebuild without a gateway restart (#8184)", () => { - const restartSandboxGateway = vi.fn(() => RESTART_SUCCEEDED); - const checkAndRecoverSandboxProcesses = vi.fn(() => ({ - checked: true, - wasRunning: true, - recovered: false, - })); - - const state = ensureHermesGatewayAfterStateRestore("alpha", "openclaw", { - restartSandboxGateway, - checkAndRecoverSandboxProcesses, - }); - - expect(state).toBe("not-applicable"); - expect(restartSandboxGateway).not.toHaveBeenCalled(); - expect(checkAndRecoverSandboxProcesses).not.toHaveBeenCalled(); - }); - - it("binds managed health to one observed replacement identity (#8472)", () => { - const order: string[] = []; - const replacement = { pid: 77, start_time: 903, drain_token: "restore-token" }; - const verification = ensureHermesGatewayAfterStateRestoreForCronGate( - "alpha", - "hermes", - { pid: 41, start_time: 902, drain_token: "restore-token" }, - { - restartSandboxGateway: () => { - order.push("restart"); - return RESTART_SUCCEEDED; - }, - observeHermesCronReplacement: () => { - order.push("observe"); - return replacement; - }, - checkAndRecoverSandboxProcesses: () => { - order.push("health"); - return { checked: true, wasRunning: true, recovered: false }; - }, - }, - ); - - expect(verification).toEqual({ state: "healthy", replacementIdentity: replacement }); - expect(order).toEqual(["restart", "observe", "health", "observe"]); - }); - - it("binds a recovered cron-gated gateway to its observed replacement identity (#8472)", () => { - const replacement = { pid: 77, start_time: 903, drain_token: "restore-token" }; - const observeHermesCronReplacement = vi.fn(() => replacement); - const checkAndRecoverSandboxProcesses = vi - .fn() - .mockReturnValueOnce({ checked: true, wasRunning: false, recovered: true }) - .mockReturnValueOnce({ checked: true, wasRunning: true, recovered: false }); - expect( - ensureHermesGatewayAfterStateRestoreForCronGate( - "alpha", - "hermes", - { pid: 41, start_time: 902, drain_token: "restore-token" }, - { - restartSandboxGateway: () => RESTART_FAILED, - observeHermesCronReplacement, - checkAndRecoverSandboxProcesses, - }, - ), - ).toEqual({ state: "recovered", replacementIdentity: replacement }); - expect(checkAndRecoverSandboxProcesses).toHaveBeenCalledTimes(2); - expect(observeHermesCronReplacement).toHaveBeenCalledTimes(3); - }); - - it("fails closed when another gateway replaces the process during health verification (#8472)", () => { - const observeHermesCronReplacement = vi - .fn() - .mockReturnValueOnce({ pid: 77, start_time: 903, drain_token: "restore-token" }) - .mockReturnValueOnce({ pid: 88, start_time: 904, drain_token: "restore-token" }); - - expect( - ensureHermesGatewayAfterStateRestoreForCronGate( - "alpha", - "hermes", - { pid: 41, start_time: 902, drain_token: "restore-token" }, - { - restartSandboxGateway: () => RESTART_SUCCEEDED, - observeHermesCronReplacement, - checkAndRecoverSandboxProcesses: () => ({ - checked: true, - wasRunning: true, - recovered: false, - }), - }, - ), - ).toEqual({ state: "unverified" }); - expect(observeHermesCronReplacement).toHaveBeenCalledTimes(2); - }); it("verifies the final cron-bound gateway without restarting after MCP restoration (#8472)", () => { const original = { pid: 41, start_time: 902, drain_token: "restore-token" }; @@ -296,70 +157,6 @@ describe("binding the Hermes gateway to restored state", () => { }); }); -describe("Hermes gateway post-restore recheck", () => { - it("accepts a gateway that becomes healthy after an inconclusive recovery check (#7084)", () => { - const checkAndRecoverSandboxProcesses = vi - .fn() - .mockReturnValueOnce({ - checked: false, - wasRunning: null, - recovered: false, - }) - .mockReturnValueOnce({ - checked: true, - wasRunning: true, - recovered: false, - }); - - expect( - ensureHermesGatewayAfterStateRestore("alpha", "hermes", { - restartSandboxGateway: () => RESTART_SUCCEEDED, - checkAndRecoverSandboxProcesses, - }), - ).toBe("healthy"); - - expect(checkAndRecoverSandboxProcesses).toHaveBeenCalledTimes(2); - }); - - it.each(["forwardRecoveryFailed", "secretBoundaryRefused", "mcpReconciliationRefused"] as const)( - "fails immediately when recovery reports %s (#7084)", - (failureFlag) => { - const checkAndRecoverSandboxProcesses = vi.fn(() => ({ - checked: true, - wasRunning: true, - recovered: false, - [failureFlag]: true, - })); - - expect( - ensureHermesGatewayAfterStateRestore("alpha", "hermes", { - restartSandboxGateway: () => RESTART_SUCCEEDED, - checkAndRecoverSandboxProcesses, - }), - ).toBe("unverified"); - - expect(checkAndRecoverSandboxProcesses).toHaveBeenCalledOnce(); - }, - ); - - it("fails closed after the bounded gateway recheck remains inconclusive (#7084)", () => { - const checkAndRecoverSandboxProcesses = vi.fn(() => ({ - checked: true, - wasRunning: false, - recovered: false, - })); - - expect( - ensureHermesGatewayAfterStateRestore("alpha", "hermes", { - restartSandboxGateway: () => RESTART_SUCCEEDED, - checkAndRecoverSandboxProcesses, - }), - ).toBe("unverified"); - - expect(checkAndRecoverSandboxProcesses).toHaveBeenCalledTimes(2); - }); -}); - describe("Hermes rebuild post-restore verification", () => { installRebuildFlowTestHooks({ acceptThirdPartySoftware: true }); diff --git a/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts b/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts index d035a4fe171..8d26fb4ea60 100644 --- a/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts +++ b/src/lib/actions/sandbox/rebuild-hermes-post-restore.ts @@ -49,6 +49,7 @@ interface HermesCronRestoreReceipt { profiles?: number; active_jobs?: number; script_jobs?: number; + rearmed_oneshots?: number; disposition: HermesCronRestoreDisposition; operator_drain_active: boolean; preserved_drain?: boolean; @@ -139,31 +140,6 @@ export interface HermesPostRestoreGatewayVerification { * identity whose MCP load just converged. A gated rebuild keeps the root-owned * cron drain active across restart, MCP restoration, and final verification. */ -export function ensureHermesGatewayAfterStateRestore( - sandboxName: string, - agentName: string, - deps: HermesPostRestoreGatewayDeps = {}, -): HermesPostRestoreGatewayState { - const restartState = restartHermesGatewayAfterStateRestore(sandboxName, agentName, deps); - return verifyHermesGatewayAfterStateRestore(sandboxName, agentName, restartState, deps); -} - -export function ensureHermesGatewayAfterStateRestoreForCronGate( - sandboxName: string, - agentName: string, - originalIdentity: HermesCronRestoreIdentity, - deps: HermesPostRestoreGatewayDeps = {}, -): HermesPostRestoreGatewayVerification { - const restartState = restartHermesGatewayAfterStateRestore(sandboxName, agentName, deps); - return verifyHermesGatewayAfterStateRestoreForCronGate( - sandboxName, - agentName, - restartState, - originalIdentity, - deps, - ); -} - export function restartHermesGatewayAfterStateRestore( sandboxName: string, agentName: string, @@ -400,6 +376,7 @@ function parseCronRestoreReceipt( isNonNegativeInteger(receipt.profiles) && isNonNegativeInteger(receipt.active_jobs) && isNonNegativeInteger(receipt.script_jobs) && + isNonNegativeInteger(receipt.rearmed_oneshots) && isReleaseDispositionValid(receipt) && hasExactReceiptFields(receipt, [ ...baseFields, @@ -408,6 +385,7 @@ function parseCronRestoreReceipt( "profiles", "active_jobs", "script_jobs", + "rearmed_oneshots", "preserved_drain", ]); break; @@ -418,6 +396,7 @@ function parseCronRestoreReceipt( isNonNegativeInteger(receipt.profiles) && isNonNegativeInteger(receipt.active_jobs) && isNonNegativeInteger(receipt.script_jobs) && + isNonNegativeInteger(receipt.rearmed_oneshots) && isReleaseDispositionValid(receipt) && hasExactReceiptFields(receipt, [ ...baseFields, @@ -426,6 +405,7 @@ function parseCronRestoreReceipt( "profiles", "active_jobs", "script_jobs", + "rearmed_oneshots", "preserved_drain", ]); } else { diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index c4360f62124..bea0cf499be 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -556,9 +556,36 @@ async function rebuildSandboxUnlocked( } rebuildPolicyHandoffManifest = recoveryBackup; retainPolicyHandoffForRecovery = true; - // The accepted replacement belongs to an earlier run. Its persisted - // gate is independent of the current backup's cron plan, so probe every - // Hermes target before retiring the replacement journal. + const restored = runRebuildRestorePhase({ + sandboxName, + targetAgentType: rebuildAgent || "openclaw", + targetImageIsCustom: Boolean(fromDockerfile), + backupManifest: recoveryBackup, + ...(recreateJournal.runtimeSelection + ? { runtimeSelection: recreateJournal.runtimeSelection } + : {}), + log, + }); + const postRestoreVerification = await runRebuildPostRestorePhase({ + sandboxName, + targetAgentName: rebuildAgent || "openclaw", + messagingPlan, + backupManifest: recoveryBackup, + mcpEntries, + ...(recreateJournal.runtimeSelection + ? { mcpRuntimeSelection: recreateJournal.runtimeSelection } + : {}), + restoreSucceeded: restored.restoreSucceeded, + preparedBackupRecovery: true, + versionCheck, + log, + bail, + }); + if (!restored.restoreSucceeded) return; + // The accepted replacement belongs to an earlier run. Keep its + // persisted gate active until the backup and all post-restore state + // have been applied and verified, then validate the restored cron tree + // before reopening dispatch or retiring recovery records. if (rebuildAgent === "hermes") { try { const outcome = recoverHermesCronRestore(sandboxName); @@ -590,31 +617,6 @@ async function rebuildSandboxUnlocked( ); } } - const restored = runRebuildRestorePhase({ - sandboxName, - targetAgentType: rebuildAgent || "openclaw", - targetImageIsCustom: Boolean(fromDockerfile), - backupManifest: recoveryBackup, - ...(recreateJournal.runtimeSelection - ? { runtimeSelection: recreateJournal.runtimeSelection } - : {}), - log, - }); - const postRestoreVerification = await runRebuildPostRestorePhase({ - sandboxName, - targetAgentName: rebuildAgent || "openclaw", - messagingPlan, - backupManifest: recoveryBackup, - mcpEntries, - ...(recreateJournal.runtimeSelection - ? { mcpRuntimeSelection: recreateJournal.runtimeSelection } - : {}), - restoreSucceeded: restored.restoreSucceeded, - preparedBackupRecovery: true, - versionCheck, - log, - bail, - }); if (retireRemovedImmutabilityState) { if (!postRestoreVerification?.mutableConfigPermissionsVerified) { return bail( diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts index 8ecaea6fc54..51978d025e7 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts @@ -6,6 +6,7 @@ import * as agentDefs from "../../agent/defs"; import * as agentRuntime from "../../agent/runtime"; import * as mutableConfigPerms from "../../sandbox/mutable-config-perms"; import * as registry from "../../state/registry"; +import * as sandboxVersion from "../../sandbox/version"; import * as messagingHostForward from "./messaging-host-forward-lifecycle"; import * as processRecovery from "./process-recovery"; import * as rebuildConfigHash from "./rebuild-config-hash"; @@ -107,6 +108,14 @@ describe("rebuild post-restore phase", () => { () => ({ agent: agentName === "openclaw" ? null : agentName }) as never, ); vi.spyOn(registry, "updateSandbox").mockReturnValue(true); + vi.spyOn(sandboxVersion, "checkAgentVersion").mockReturnValue({ + sandboxVersion: null, + expectedVersion: null, + isStale: false, + verificationFailed: true, + detectionMethod: "unavailable", + unavailableReason: "no-expected-version", + }); vi.spyOn(messagingHostForward, "ensureMessagingHostForwardAfterRebuild").mockImplementation( () => { order.push("host-forward"); @@ -341,6 +350,90 @@ describe("rebuild post-restore phase", () => { expect(verification).toEqual({ mutableConfigPermissionsVerified: true }); }); + it("rejects a replacement whose live version does not match the rebuild target", async () => { + agentName = "hermes"; + vi.mocked(agentDefs.loadAgent).mockReturnValue({ + name: "hermes", + displayName: "Hermes Agent", + expectedVersion: "0.20.6", + } as never); + vi.mocked(sandboxVersion.checkAgentVersion).mockReturnValue({ + sandboxVersion: "0.19.0", + expectedVersion: "0.20.6", + isStale: true, + verificationFailed: false, + detectionMethod: "ssh-exec", + }); + const args = { + ...input(), + versionCheck: { expectedVersion: "0.20.6" } as never, + hermesCronRestoreIdentity: { + pid: 41, + start_time: 902, + drain_token: "restore-token", + }, + }; + + await runRebuildPostRestorePhase(args); + + expect(sandboxVersion.checkAgentVersion).toHaveBeenCalledWith("alpha", { forceProbe: true }); + expect(registry.updateSandbox).toHaveBeenNthCalledWith(1, "alpha", { + agentVersion: null, + }); + expect(registry.updateSandbox).toHaveBeenNthCalledWith(2, "alpha", { + agentVersion: null, + }); + expect(registry.updateSandbox).not.toHaveBeenCalledWith( + "alpha", + expect.objectContaining({ agentVersion: expect.stringMatching(/.+/) }), + ); + expect(args.bail).toHaveBeenCalledWith( + "Replacement agent version did not match the authoritative rebuild target.", + ); + expect( + rebuildHermesPostRestore.completeHermesCronRestoreAfterGatewayReplacement, + ).not.toHaveBeenCalled(); + expect(messagingHostForward.ensureMessagingHostForwardAfterRebuild).not.toHaveBeenCalled(); + expect(vi.mocked(console.error).mock.calls.flat().join("\n")).toContain( + "Hermes cron dispatch remains drained", + ); + expect(vi.mocked(console.log).mock.calls.flat().join("\n")).not.toContain( + "rebuilt successfully", + ); + }); + + it("records the live replacement version only after an exact forced probe", async () => { + agentName = "hermes"; + vi.mocked(agentDefs.loadAgent).mockReturnValue({ + name: "hermes", + displayName: "Hermes Agent", + expectedVersion: "0.20.6", + } as never); + vi.mocked(sandboxVersion.checkAgentVersion).mockReturnValue({ + sandboxVersion: "0.20.6", + expectedVersion: "0.20.6", + isStale: false, + verificationFailed: false, + detectionMethod: "ssh-exec", + }); + const args = { + ...input(), + versionCheck: { expectedVersion: "0.20.6" } as never, + }; + + await runRebuildPostRestorePhase(args); + + expect(sandboxVersion.checkAgentVersion).toHaveBeenCalledWith("alpha", { forceProbe: true }); + expect(registry.updateSandbox).toHaveBeenLastCalledWith( + "alpha", + expect.objectContaining({ agentVersion: "0.20.6" }), + ); + expect(args.bail).not.toHaveBeenCalled(); + expect(vi.mocked(console.log).mock.calls.flat().join("\n")).toContain( + "Sandbox 'alpha' rebuild completed", + ); + }); + it("does not claim mutable Hermes posture without the exact sandbox proof", async () => { agentName = "hermes"; vi.mocked(mutableConfigPerms.inspectMutableHermesConfigPerms).mockReturnValue({ diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts index dfc4b3900ab..a287a66236a 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts @@ -6,7 +6,7 @@ import * as agentRuntime from "../../agent/runtime"; import { CLI_NAME } from "../../cli/branding"; import { D, G, R, YW } from "../../cli/terminal-style"; import type { SandboxMessagingPlan } from "../../messaging"; -import type * as sandboxVersion from "../../sandbox/version"; +import * as sandboxVersion from "../../sandbox/version"; import { inspectMutableHermesConfigPerms, repairMutableConfigPerms, @@ -48,6 +48,13 @@ export { runHermesCronRestoreTransaction, } from "./rebuild-hermes-post-restore"; +/** Probe the recreated runtime instead of accepting its requested version metadata. */ +function probeRebuiltAgentVersion( + sandboxName: string, +): ReturnType { + return sandboxVersion.checkAgentVersion(sandboxName, { forceProbe: true }); +} + const OPENCLAW_DOCTOR_TIMEOUT_MS = 5 * 60_000; export function printHermesCronRestoreRecoveryCommand( @@ -303,6 +310,38 @@ export async function runRebuildPostRestorePhase( }; const hermesGatewayRestoreState = hermesGatewayVerification.state; const hermesGatewayRestoreUnverified = hermesGatewayRestoreState === "unverified"; + let verifiedAgentVersion: string | null = null; + if (versionCheck.expectedVersion) { + // The replacement runtime is the only authority for the completed rebuild + // version. Clear create-time bookkeeping before the forced live probe so a + // failed probe cannot leave the requested version recorded as observed. + registry.updateSandbox(sandboxName, { agentVersion: null }); + const rebuiltVersion = probeRebuiltAgentVersion(sandboxName); + if ( + rebuiltVersion.verificationFailed || + rebuiltVersion.sandboxVersion !== versionCheck.expectedVersion + ) { + // checkAgentVersion caches a successful probe. Do not retain metadata + // from a replacement that this rebuild rejects. + registry.updateSandbox(sandboxName, { agentVersion: null }); + const observed = rebuiltVersion.sandboxVersion ?? "unverified"; + const detail = ` Replacement agent version did not match the rebuild target (expected ${versionCheck.expectedVersion}, observed ${observed}).`; + if (hermesCronRestoreIdentity) { + return bailAfterHermesCronRestoreFailure( + sandboxName, + backupManifest, + `${detail} Hermes cron dispatch remains drained.`, + "Replacement agent version did not match the authoritative rebuild target.", + bail, + mcpBridgeRestoreUnverified ? () => printMcpRestoreRecovery(sandboxName, true) : undefined, + ); + } + console.error(detail); + bail("Replacement agent version did not match the authoritative rebuild target."); + return; + } + verifiedAgentVersion = rebuiltVersion.sandboxVersion; + } if ( targetAgentName === "hermes" && (hermesGatewayRestoreState === "healthy" || hermesGatewayRestoreState === "recovered") @@ -379,7 +418,7 @@ export async function runRebuildPostRestorePhase( console.log(` ${G}\u2713${R} Hermes gateway recovered after state restore`); } registry.updateSandbox(sandboxName, { - agentVersion: agentDef.expectedVersion || null, + agentVersion: verifiedAgentVersion, }); log(`Registry updated: agentVersion=${agentDef.expectedVersion}`); diff --git a/src/lib/agent/base-image.test.ts b/src/lib/agent/base-image.test.ts index 578f4fa0ec6..9f6c1856401 100644 --- a/src/lib/agent/base-image.test.ts +++ b/src/lib/agent/base-image.test.ts @@ -54,13 +54,6 @@ function makeDifferingImageInspection( const AGENTS_DIR = path.resolve(import.meta.dirname, "../../../agents"); -function declaresCorporateCaBuildArg(dockerfilePath: string): boolean { - return ( - fs.existsSync(dockerfilePath) && - fs.readFileSync(dockerfilePath, "utf8").includes("ARG NEMOCLAW_CORPORATE_CA_B64") - ); -} - function readManifestExpectedVersion(agentName: string): string { const manifestPath = path.join(AGENTS_DIR, agentName, "manifest.yaml"); const expectedVersion = readString(loadManifestRecord(manifestPath), "expected_version"); @@ -68,18 +61,7 @@ function readManifestExpectedVersion(agentName: string): string { return expectedVersion ?? ""; } -// Read the agent names from the checked-in Dockerfiles so a base image that -// starts consuming the corporate CA cannot ship without the build argument. -const CORPORATE_CA_BASE_IMAGE_AGENTS = fs - .readdirSync(AGENTS_DIR) - .filter((agentName) => - declaresCorporateCaBuildArg(path.join(AGENTS_DIR, agentName, "Dockerfile.base")), - ); - -expect( - CORPORATE_CA_BASE_IMAGE_AGENTS, - "expected at least one agent base image to declare the corporate CA build arg", -).not.toHaveLength(0); +const CORPORATE_CA_BASE_IMAGE_AGENTS = ["langchain-deepagents-code", "pi"] as const; describe("agent base image provisioning", () => { beforeEach(() => { @@ -178,6 +160,26 @@ describe("agent base image provisioning", () => { }); }); + it("initializes the lazy Hermes MCP runtime before accepting a published base", () => { + const imageRef = "hermes-base:current"; + + withMockedDocker(({ ensureAgentBaseImage, dockerCaptureMock, resolveSandboxBaseImageMock }) => { + ensureAgentBaseImage(makeAgent()); + const options = resolveSandboxBaseImageMock.mock.calls[0]?.[0] as { + validateImage?: (candidate: string) => boolean; + }; + + expect(options.validateImage?.(imageRef)).toBe(true); + expect(dockerCaptureMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + "/opt/hermes/.venv/bin/python", + imageRef, + expect.stringContaining("mcp_tool._ensure_mcp_sdk() or sys.exit(1)"), + ]), + ); + }); + }); + it( "reuses a compatible resolved agent base image during normal onboarding", () => { @@ -530,6 +532,17 @@ describe("agent base image provisioning", () => { }, ); + it("omits corporate CA build inputs from Hermes base image builds (#8119)", () => { + vi.stubEnv("NEMOCLAW_CORPORATE_CA_BUNDLE", writeCa(tmpDir())); + withMockedDocker(({ ensureAgentBaseImage, dockerBuildMock }) => { + ensureAgentBaseImage(makeAgent(), { forceBaseImageRebuild: true }); + + expect(dockerBuildMock.mock.calls[0]?.[3]).toEqual( + expect.objectContaining({ buildArgs: undefined }), + ); + }); + }); + it("omits corporate CA build inputs when corporate CA import is disabled (#8119)", () => { vi.stubEnv("NEMOCLAW_CORPORATE_CA_BUNDLE", writeCa(tmpDir())); vi.stubEnv("NEMOCLAW_CORPORATE_CA_IMPORT", "0"); diff --git a/src/lib/agent/base-image.ts b/src/lib/agent/base-image.ts index 23eb7fe089d..69ae2fdece2 100644 --- a/src/lib/agent/base-image.ts +++ b/src/lib/agent/base-image.ts @@ -316,7 +316,7 @@ export function hermesBaseImageSupportsMcp(imageRef: string): boolean { imageRef, "-I", "-c", - `import importlib.metadata as metadata; import sys; import acp; import mcp; from acp_adapter.server import HermesACPAgent; from tools import mcp_tool; metadata.version("agent-client-protocol") == "0.9.0" or sys.exit(1); getattr(mcp_tool, "_MCP_AVAILABLE", False) or sys.exit(1); getattr(mcp_tool, "_MCP_HTTP_AVAILABLE", False) or sys.exit(1); print("${HERMES_MCP_RUNTIME_PROBE_OK}")`, + `import importlib.metadata as metadata; import sys; import acp; import mcp; from acp_adapter.server import HermesACPAgent; from tools import mcp_tool; metadata.version("agent-client-protocol") == "0.9.0" or sys.exit(1); mcp_tool._ensure_mcp_sdk() or sys.exit(1); getattr(mcp_tool, "_MCP_AVAILABLE", False) or sys.exit(1); getattr(mcp_tool, "_MCP_HTTP_AVAILABLE", False) or sys.exit(1); print("${HERMES_MCP_RUNTIME_PROBE_OK}")`, ], { ignoreError: true, timeout: 20_000 }, ); diff --git a/src/lib/agent/candidate-authority.ts b/src/lib/agent/candidate-authority.ts index c9b0433e53b..4b9b7aa6a1d 100644 --- a/src/lib/agent/candidate-authority.ts +++ b/src/lib/agent/candidate-authority.ts @@ -13,8 +13,8 @@ export const CANDIDATE_QUALIFICATION_RECEIPT_DIGESTS: Readonly< Record > = Object.freeze({ pi: Object.freeze([ - "aa0cf71163ac4741e70b794e8a0d9afdb49e1c16770b0ad2fed33ccb2c65c8c7", - "0eba30f97b70724a9f35a5193ba46358caae15708e3c7c87d2488434d0950cbc", + "8c53f248ddcef01931f48b66624421763c5c892ffb32f1936a6dccca6344e938", + "2cca1daacbead12b9ba24d7b9e0859b6c26f5b7b1e0a2179a176ae1155176c40", ]), }); diff --git a/src/lib/domain/lifecycle/hermes-definition.ts b/src/lib/domain/lifecycle/hermes-definition.ts index e5c522531e1..e41b5c1d664 100644 --- a/src/lib/domain/lifecycle/hermes-definition.ts +++ b/src/lib/domain/lifecycle/hermes-definition.ts @@ -4,6 +4,6 @@ /** Hermes and OpenShell versions supported by the first lifecycle API. */ export const HERMES_LIFECYCLE_DEFINITION = Object.freeze({ agent: "hermes" as const, - agentVersion: "0.19.0" as const, + agentVersion: "0.20.6" as const, openshellVersion: "0.0.106" as const, }); diff --git a/src/lib/domain/lifecycle/hermes-plan.test.ts b/src/lib/domain/lifecycle/hermes-plan.test.ts index 390c5a3d28e..79e286b8ad0 100644 --- a/src/lib/domain/lifecycle/hermes-plan.test.ts +++ b/src/lib/domain/lifecycle/hermes-plan.test.ts @@ -44,7 +44,7 @@ describe("Hermes lifecycle planning", () => { value: { apiVersion: "v1alpha1", operation: "observe", - agent: { name: "hermes", version: "0.19.0" }, + agent: { name: "hermes", version: "0.20.6" }, target: request.target, sandbox: request.sandbox, checks: [ diff --git a/src/lib/domain/sandbox/connect-env.test.ts b/src/lib/domain/sandbox/connect-env.test.ts index 01a739075ca..27a081e313f 100644 --- a/src/lib/domain/sandbox/connect-env.test.ts +++ b/src/lib/domain/sandbox/connect-env.test.ts @@ -21,7 +21,7 @@ describe("sandbox connect environment helpers", () => { expect(NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS).toEqual([ "v2026.6.19", "v2026.7.1", - "v2026.7.20", + "v2026.8.27", ]); }); diff --git a/src/lib/domain/sandbox/connect-env.ts b/src/lib/domain/sandbox/connect-env.ts index 0278aaea94d..04213c42cad 100644 --- a/src/lib/domain/sandbox/connect-env.ts +++ b/src/lib/domain/sandbox/connect-env.ts @@ -7,7 +7,7 @@ export const NEMOCLAW_HERMES_LIGHT_SKIN_NAME = "nemoclaw-light"; export const NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS = [ "v2026.6.19", "v2026.7.1", - "v2026.7.20", + "v2026.8.27", ] as const; // Compatibility boundary: remove this NemoClaw-managed light skin once the diff --git a/src/lib/messaging/channels/googlechat/runtime/hermes-adapter.py b/src/lib/messaging/channels/googlechat/runtime/hermes-adapter.py index fa85fa20664..13dd12acdbf 100644 --- a/src/lib/messaging/channels/googlechat/runtime/hermes-adapter.py +++ b/src/lib/messaging/channels/googlechat/runtime/hermes-adapter.py @@ -245,7 +245,7 @@ def _new_authed_http(self): async def connect(self, *, is_reconnect: bool = False) -> bool: """Run the bundled connect(), then start the REST pull it skipped.""" connected = await super().connect(is_reconnect=is_reconnect) - # Hermes v2026.7.20 builds a fresh adapter for every reconnect, so this + # Hermes v2026.8.27 builds a fresh adapter for every reconnect, so this # never runs twice on one instance; a release that reuses the instance # would need the running pull replaced here. if connected and self._sandbox_subscription: diff --git a/src/lib/onboard/base-image-resolution-flow.test.ts b/src/lib/onboard/base-image-resolution-flow.test.ts index 71de670d77d..de6572aeb2d 100644 --- a/src/lib/onboard/base-image-resolution-flow.test.ts +++ b/src/lib/onboard/base-image-resolution-flow.test.ts @@ -119,6 +119,46 @@ describe("base image resolution flow", () => { expect(context.preResolvedMetadata).toBe(resolvedMetadata); }); + it("uses the authenticated outer rebuild resolution instead of the source sandbox hint", () => { + const targetMetadata: SandboxBaseImageResolutionMetadata = { + ...recordedMetadata, + key: "target-key", + ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:target", + digest: "sha256:target", + imageId: "sha256:target-image", + }; + const context = createBaseImageResolutionContext({ + fresh: false, + initialHint: recordedMetadata, + initialPreResolvedMetadata: targetMetadata, + env: {}, + }); + const createAgentSandbox = vi.fn(() => ({ + buildCtx: "/tmp/hermes-build", + stagedDockerfile: "/tmp/hermes-build/Dockerfile", + baseImageResolutionMetadata: targetMetadata, + })); + + createAgentSandboxWithResolution( + context, + { name: "hermes" } as AgentDefinition, + createAgentSandbox, + ); + + expect(createAgentSandbox).toHaveBeenCalledWith( + { name: "hermes" }, + { + resolutionHint: targetMetadata, + forceBaseImageRefresh: false, + }, + ); + expect(getBaseImageResolutionPatchOptions(context)).toEqual({ + resolutionHint: targetMetadata, + preResolvedBaseImageMetadata: targetMetadata, + forceBaseImageRefresh: false, + }); + }); + it("retains canonical outer metadata when a bound local lease emits no metadata", () => { const imageId = `sha256:${"a".repeat(64)}`; const stableMetadata: SandboxBaseImageResolutionMetadata = { diff --git a/src/lib/onboard/base-image-resolution-flow.ts b/src/lib/onboard/base-image-resolution-flow.ts index dda340e79be..2141e71705b 100644 --- a/src/lib/onboard/base-image-resolution-flow.ts +++ b/src/lib/onboard/base-image-resolution-flow.ts @@ -41,9 +41,13 @@ export function createBaseImageResolutionContext(options: { initialPreResolvedMetadata?: SandboxBaseImageResolutionMetadata | null; env?: NodeJS.ProcessEnv; }): BaseImageResolutionContext { + const preResolvedMetadata = options.initialPreResolvedMetadata ?? null; return { - resolutionHint: options.initialHint ?? null, - preResolvedMetadata: options.initialPreResolvedMetadata ?? null, + // A rebuild's authenticated outer resolution is the create authority. The + // source sandbox hint is cleanup/recovery context and must not select the + // replacement image when both values are present. + resolutionHint: preResolvedMetadata ?? options.initialHint ?? null, + preResolvedMetadata, forceRefresh: options.fresh || isSandboxBaseImageRefreshRequested(options.env ?? process.env), }; } diff --git a/src/lib/onboard/experimental/hermes-portable-build-context-files.ts b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts index 88106bc0787..cd0063068bb 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context-files.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts @@ -3,6 +3,7 @@ /** Exact shipped files admitted by the schema-7 Hermes Dockerfile COPY contract. */ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [ + { path: "agents/hermes/a2a-neutral.patch", mode: "100644" }, { path: "agents/hermes/build-mcp-digest.py", mode: "100644" }, { path: "agents/hermes/config/build-env.ts", mode: "100644" }, { path: "agents/hermes/config/generate.ts", mode: "100644" }, @@ -17,10 +18,13 @@ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [ { path: "agents/hermes/cron-restore-control.py", mode: "100644" }, { path: "agents/hermes/dashboard-external-host.patch", mode: "100644" }, { path: "agents/hermes/Dockerfile", mode: "100644" }, + { path: "agents/hermes/finalize-image-layout.sh", mode: "100755" }, { path: "agents/hermes/finalize-tirith-marker.py", mode: "100755" }, { path: "agents/hermes/generate-config.ts", mode: "100644" }, { path: "agents/hermes/hermes-cli-adapter-v1.json", mode: "100644" }, { path: "agents/hermes/hermes-wrapper.py", mode: "100755" }, + { path: "agents/hermes/hindsight-client-probe-requirements.txt", mode: "100644" }, + { path: "agents/hermes/hindsight-lazy-integrity.patch", mode: "100644" }, { path: "agents/hermes/host/managed-tool-gateway-matrix.json", mode: "100644" }, { path: "agents/hermes/image-build-probes.py", mode: "100644" }, { path: "agents/hermes/managed_policy.py", mode: "100644" }, @@ -39,7 +43,7 @@ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [ { path: "agents/hermes/plugin/plugin.yaml", mode: "100644" }, { path: "agents/hermes/runtime-config-guard.py", mode: "100755" }, { path: "agents/hermes/runtime-boundaries.patch", mode: "100644" }, - { path: "agents/hermes/security-dependencies.patch", mode: "100644" }, + { path: "agents/hermes/secure-dir-skip-chmod.patch", mode: "100644" }, { path: "agents/hermes/seed-dashboard-config.py", mode: "100755" }, { path: "agents/hermes/start.sh", mode: "100755" }, { path: "agents/hermes/validate-cli-adapter.py", mode: "100755" }, diff --git a/src/lib/onboard/experimental/hermes-portable-build-context.test.ts b/src/lib/onboard/experimental/hermes-portable-build-context.test.ts index 7d56a74a284..774b3ec71b4 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context.test.ts @@ -176,6 +176,11 @@ describe("Hermes portable staged build context", testTimeoutOptions(30_000), () expect( fs.existsSync(path.join(first.buildContextPath, "agents/hermes/plugin/__pycache__")), ).toBe(false); + expect( + fs.existsSync( + path.join(first.buildContextPath, "agents/hermes/security-dependencies.patch"), + ), + ).toBe(false); const reused = plan.materialize(contextInput()); expect(reused.buildContextPath).toBe(first.buildContextPath); diff --git a/src/lib/onboard/experimental/hermes-portable-build-context.ts b/src/lib/onboard/experimental/hermes-portable-build-context.ts index 938b866f14d..5073befbfe7 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context.ts @@ -30,16 +30,22 @@ const SOURCE_DOCKERFILE_RELATIVE_PATH = "agents/hermes/Dockerfile" as const; const CONTEXT_DOCKERFILE_RELATIVE_PATH = "Dockerfile" as const; const LOCAL_COPY_SOURCES = [ + "agents/hermes/a2a-neutral.patch", "agents/hermes/build-mcp-digest.py", "agents/hermes/config/", "agents/hermes/cron-restore-control.py", "agents/hermes/dashboard-external-host.patch", + "agents/hermes/finalize-image-layout.sh", "agents/hermes/finalize-tirith-marker.py", "agents/hermes/generate-config.ts", "agents/hermes/hermes-cli-adapter-v1.json", "agents/hermes/hermes-wrapper.py", + "agents/hermes/hindsight-client-probe-requirements.txt", + "agents/hermes/hindsight-lazy-integrity.patch", "agents/hermes/host/managed-tool-gateway-matrix.json", "agents/hermes/image-build-probes.py", + // Dockerfile copies the policy into build-time and runtime locations. + "agents/hermes/managed_policy.py", "agents/hermes/managed_policy.py", "agents/hermes/mcp-config-transaction.py", "agents/hermes/patch-cron-execution-runtime.py", @@ -56,7 +62,7 @@ const LOCAL_COPY_SOURCES = [ "agents/hermes/plugin/plugin.yaml", "agents/hermes/runtime-config-guard.py", "agents/hermes/runtime-boundaries.patch", - "agents/hermes/security-dependencies.patch", + "agents/hermes/secure-dir-skip-chmod.patch", "agents/hermes/seed-dashboard-config.py", "agents/hermes/start.sh", "agents/hermes/validate-cli-adapter.py", diff --git a/src/lib/onboard/experimental/hermes-portable-contract.test.ts b/src/lib/onboard/experimental/hermes-portable-contract.test.ts index a78267e3e2f..9ceae182f75 100644 --- a/src/lib/onboard/experimental/hermes-portable-contract.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-contract.test.ts @@ -95,10 +95,10 @@ describe("Hermes portable startup contract", () => { devicePairing: false, configDir: "/sandbox/.hermes", }); - expect(agent.expected_version).toBe("0.19.0"); + expect(agent.expected_version).toBe("0.20.6"); }); - it.each([undefined, "", "0.19.1"])( + it.each([undefined, "", "0.19.0"])( "rejects Hermes manifest version %j outside the accepted portable matrix (#9203)", (expectedVersion) => { const agent = copyAgent(); @@ -121,7 +121,7 @@ describe("Hermes portable startup contract", () => { sandboxName: SANDBOX, startupArgv: startupArgv(), }); - setExpectedManifestVersion(accepted, "0.19.1"); + setExpectedManifestVersion(accepted, "0.19.0"); expect(() => assertCurrentHermesPortableStartupContract(contract, { diff --git a/src/lib/onboard/experimental/hermes-portable-contract.ts b/src/lib/onboard/experimental/hermes-portable-contract.ts index f3e8b4e9661..408f4fbad64 100644 --- a/src/lib/onboard/experimental/hermes-portable-contract.ts +++ b/src/lib/onboard/experimental/hermes-portable-contract.ts @@ -321,7 +321,7 @@ export function resolveHermesPortableStartupContract( } if ( manifest.name !== "hermes" || - manifest.expectedVersion !== "0.19.0" || + manifest.expectedVersion !== "0.20.6" || manifest.gatewayCommand !== "hermes gateway run" || manifest.runtime.interactive_command !== "hermes" || manifest.healthProbe?.url !== "http://localhost:8642/health" || diff --git a/test/agents/hermes/hermes-cli-adapter-validator.test.ts b/test/agents/hermes/hermes-cli-adapter-validator.test.ts index 0bcb7d71ab0..6a08d8f92f3 100644 --- a/test/agents/hermes/hermes-cli-adapter-validator.test.ts +++ b/test/agents/hermes/hermes-cli-adapter-validator.test.ts @@ -62,7 +62,7 @@ function runValidator( try { const packageDir = path.join(fixture, "hermes_cli"); fs.mkdirSync(packageDir); - fs.writeFileSync(path.join(packageDir, "__init__.py"), '__version__ = "0.19.0"\n'); + fs.writeFileSync(path.join(packageDir, "__init__.py"), '__version__ = "0.20.6"\n'); fs.writeFileSync(path.join(packageDir, "_parser.py"), parserFixture); fs.writeFileSync(path.join(packageDir, "main.py"), mainFixture); const contractPath = path.join(fixture, "adapter.json"); diff --git a/test/agents/hermes/hermes-cron-execution-runtime-patch.test.ts b/test/agents/hermes/hermes-cron-execution-runtime-patch.test.ts index 43f0828f89b..10b18229b11 100644 --- a/test/agents/hermes/hermes-cron-execution-runtime-patch.test.ts +++ b/test/agents/hermes/hermes-cron-execution-runtime-patch.test.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 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"; @@ -11,17 +10,19 @@ import { afterEach, describe, expect, it } from "vitest"; const root = path.join(import.meta.dirname, "../../.."); const patcher = path.join(root, "agents", "hermes", "patch-cron-execution-runtime.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 probes = path.join(root, "agents", "hermes", "image-build-probes.py"); const fixtures: string[] = []; const upstreamExecutions = `\ +import sqlite3 from hermes_constants import get_hermes_home -EXECUTIONS_FILE = get_hermes_home().resolve() / "cron" / "executions.db" +EXECUTIONS_FILE = None + +def _connect(): + path = EXECUTIONS_FILE or (get_hermes_home().resolve() / "cron" / "executions.db") + path.parent.mkdir(parents=True, exist_ok=True) + return sqlite3.connect(path) `; const upstreamBackup = `\ @@ -32,13 +33,13 @@ _QUICK_STATE_FILES = ( ) `; -function fixtureFiles(options: { executions?: string; backup?: string } = {}) { +function fixtureFiles() { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-cron-runtime-")); fixtures.push(fixture); const executions = path.join(fixture, "executions.py"); const backup = path.join(fixture, "backup.py"); - fs.writeFileSync(executions, options.executions ?? upstreamExecutions); - fs.writeFileSync(backup, options.backup ?? upstreamBackup); + fs.writeFileSync(executions, upstreamExecutions); + fs.writeFileSync(backup, upstreamBackup); return { executions, backup }; } @@ -56,74 +57,123 @@ afterEach(() => { }); describe("Hermes cron execution runtime patch", () => { - it("relocates the ledger and quick snapshot entry together and remains idempotent", () => { + it("opens the ledger in writable runtime state and snapshots that path", () => { const files = fixtureFiles(); - const first = runPatcher(files.executions, files.backup); - expect(first.status, first.stderr).toBe(0); - expect(fs.readFileSync(files.executions, "utf8")).toContain( - 'get_hermes_home().resolve() / "runtime" / "cron-executions.db"', + const result = runPatcher(files.executions, files.backup); + expect(result.status, result.stderr).toBe(0); + const fixture = path.dirname(files.executions); + const probe = `\ +import importlib.util +import json +import pathlib +import sys +import types + +home = pathlib.Path(sys.argv[3]).resolve() +constants = types.ModuleType("hermes_constants") +constants.get_hermes_home = lambda: home +sys.modules["hermes_constants"] = constants + +def load(name, source): + spec = importlib.util.spec_from_file_location(name, source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +executions = load("patched_executions", sys.argv[1]) +backup = load("patched_backup", sys.argv[2]) +connection = executions._connect() +database_path = pathlib.Path(connection.execute("PRAGMA database_list").fetchone()[2]) +connection.close() +print(json.dumps({"database": str(database_path.relative_to(home)), "snapshot": list(backup._QUICK_STATE_FILES)})) +`; + const probeResult = spawnSync( + "python3", + ["-I", "-c", probe, files.executions, files.backup, fixture], + { encoding: "utf8", timeout: 5000 }, ); - expect(fs.readFileSync(files.backup, "utf8")).toContain('"runtime/cron-executions.db"'); - expect(fs.readFileSync(files.executions, "utf8")).not.toContain('/ "cron" / "executions.db"'); - expect(fs.readFileSync(files.backup, "utf8")).not.toContain('"cron/executions.db"'); - - const second = runPatcher(files.executions, files.backup); - expect(second.status, second.stderr).toBe(0); + expect(probeResult.status, probeResult.stderr).toBe(0); + expect(JSON.parse(probeResult.stdout)).toEqual({ + database: "runtime/cron-executions.db", + snapshot: ["state.db", "cron/jobs.json", "runtime/cron-executions.db"], + }); + expect(fs.existsSync(path.join(fixture, "cron", "executions.db"))).toBe(false); }); - it("fails closed before either file changes when a pinned source shape drifts", () => { - const driftedBackup = upstreamBackup.replace( - '"cron/executions.db"', - '"cron/execution-history.db"', - ); - const files = fixtureFiles({ backup: driftedBackup }); - - const result = runPatcher(files.executions, files.backup); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("cron execution runtime source shape changed"); - expect(fs.readFileSync(files.executions, "utf8")).toBe(upstreamExecutions); - expect(fs.readFileSync(files.backup, "utf8")).toBe(driftedBackup); - }); + function runCronRuntimeProbe(databasePath: string) { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-cron-probe-")); + fixtures.push(fixture); + const script = String.raw` +import importlib.util +import sqlite3 +import sys +import types +from pathlib import Path + +home = Path(sys.argv[2]) + +cron = types.ModuleType("cron") +cron.__path__ = [] +executions = types.ModuleType("cron.executions") +executions.EXECUTIONS_FILE = None + +def connect(): + path = executions.EXECUTIONS_FILE or ( + get_hermes_home().resolve() / sys.argv[3] + ) + path.parent.mkdir(parents=True, exist_ok=True) + return sqlite3.connect(path) + +executions._connect = connect +cron.executions = executions +sys.modules["cron"] = cron +sys.modules["cron.executions"] = executions + +hermes_cli = types.ModuleType("hermes_cli") +hermes_cli.__path__ = [] +backup = types.ModuleType("hermes_cli.backup") +backup._QUICK_STATE_FILES = ( + "state.db", + "cron/jobs.json", + "runtime/cron-executions.db", +) +hermes_cli.backup = backup +sys.modules["hermes_cli"] = hermes_cli +sys.modules["hermes_cli.backup"] = backup + +constants = types.ModuleType("hermes_constants") +def get_hermes_home(): + return home +constants.get_hermes_home = get_hermes_home +sys.modules["hermes_constants"] = constants + +spec = importlib.util.spec_from_file_location("image_build_probes", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +module.verify_cron_runtime_source() +`; - it("rejects a partially applied pair instead of splitting the runtime contract", () => { - const files = fixtureFiles({ - executions: upstreamExecutions.replace( - '/ "cron" / "executions.db"', - '/ "runtime" / "cron-executions.db"', - ), + const result = spawnSync("python3", ["-I", "-c", script, probes, fixture, databasePath], { + encoding: "utf8", + timeout: 5000, }); - const result = runPatcher(files.executions, files.backup); + return { fixture, result }; + } - expect(result.status).toBe(1); - expect(result.stderr).toContain("only partially applied"); - expect(fs.readFileSync(files.backup, "utf8")).toBe(upstreamBackup); + it("probes the resolved SQLite ledger path instead of the optional override", () => { + const { fixture, result } = runCronRuntimeProbe("runtime/cron-executions.db"); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(path.join(fixture, "runtime", "cron-executions.db"))).toBe(true); }); - it("hash-binds both upstream modules and requires installed-path build probes", () => { - const digest = createHash("sha256").update(fs.readFileSync(patcher)).digest("hex"); + it("rejects the old cron ledger path even when the optional override stays empty", () => { + const { fixture, result } = runCronRuntimeProbe("cron/executions.db"); - expect(dockerfile).toContain(`ARG NEMOCLAW_HERMES_CRON_RUNTIME_PATCHER_SHA256=${digest}`); - expect(dockerfile).toContain( - "ARG NEMOCLAW_HERMES_CRON_EXECUTIONS_SOURCE_SHA256=" + - "b37215a27a453191420622f78dc8962fa44feac2521a6f51d71b18831e7cacb7", - ); - expect(dockerfile).toContain( - "ARG NEMOCLAW_HERMES_BACKUP_SOURCE_SHA256=" + - "1bcef6f736f1d52055837789f24becdba4a670f0a1abb5ac9973b1a1a7306f35", - ); - expect(dockerfile).toContain( - "COPY agents/hermes/patch-cron-execution-runtime.py " + - "/opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py", - ); - expect(dockerfile).toMatch( - /patch-cron-execution-runtime[.]py \\\n\s+--executions \/opt\/hermes\/cron\/executions[.]py \\\n\s+--backup \/opt\/hermes\/hermes_cli\/backup[.]py/u, - ); - expect(imageBuildProbes).toContain( - 'expected = get_hermes_home().resolve() / "runtime" / "cron-executions.db"', - ); - expect(imageBuildProbes).toContain('assert "cron/executions.db" not in _QUICK_STATE_FILES'); + expect(result.status).toBe(1); + expect(result.stderr).toContain("AssertionError"); + expect(fs.existsSync(path.join(fixture, "cron", "executions.db"))).toBe(true); }); }); diff --git a/test/agents/hermes/hermes-cron-restore-control.test.ts b/test/agents/hermes/hermes-cron-restore-control.test.ts index ab2be645e64..1348ed64a84 100644 --- a/test/agents/hermes/hermes-cron-restore-control.test.ts +++ b/test/agents/hermes/hermes-cron-restore-control.test.ts @@ -27,6 +27,8 @@ const LIFECYCLE_HARNESS = String.raw` import importlib.util import os import sys +import types +from datetime import datetime, timezone from pathlib import Path spec = importlib.util.spec_from_file_location("cron_restore_control", sys.argv[1]) @@ -133,6 +135,24 @@ class Status: drain = DrainControl() status = Status() module._load_gateway_modules = lambda: (drain, status) +rearm_calls = [] +delayed_one_shot_due_at = None +cron_package = types.ModuleType("cron") +cron_jobs = types.ModuleType("cron.jobs") +def rearm_nemoclaw_drained_oneshots(not_before, profile_homes): + if not module._marker_path().exists(): + raise AssertionError("one-shot re-arm ran without the NemoClaw drain") + if not profile_homes or profile_homes[0] != module.HERMES_HOME: + raise AssertionError("one-shot re-arm did not include the default Hermes profile") + rearm_calls.append(not_before.isoformat()) + if delayed_one_shot_due_at is not None and not_before > delayed_one_shot_due_at: + return 0 + return 1 +cron_jobs.rearm_nemoclaw_drained_oneshots = rearm_nemoclaw_drained_oneshots +cron_package.jobs = cron_jobs +sys.modules["cron"] = cron_package +sys.modules["cron.jobs"] = cron_jobs +RECOVERY_STARTED_AT_NS = 1_600_000_000_000_000_000 try: if scenario == "success": @@ -289,9 +309,9 @@ try: release_events = [] original_write_release_recovery = module._write_release_recovery original_remove_owned_drain = module._remove_owned_drain - def write_release_recovery(drain_token): + def write_release_recovery(drain_token, drain_started_at_ns): release_events.append("recovery-write-started") - original_write_release_recovery(drain_token) + original_write_release_recovery(drain_token, drain_started_at_ns) release_events.append("recovery-write-durable") def remove_owned_drain(drain_token): release_events.append("drain-delete-started") @@ -309,13 +329,20 @@ try: module.observe_replacement(41, 902, token) fail_directory_sync_on(1) module.complete_replacement(41, 902, 77, 903, token) + elif scenario == "rearm-failure": + token = module.begin_drain() + module.validate_restore(41, 902, token) + def fail_rearm(_not_before, _profile_homes): + raise RuntimeError("simulated re-arm failure") + cron_jobs.rearm_nemoclaw_drained_oneshots = fail_rearm + module.recover_drain() elif scenario == "existing-recovery-sync-failure": token = module.begin_drain() module.validate_restore(41, 902, token) status.payload["pid"] = 77 status.payload["start_time"] = 903 module.observe_replacement(41, 902, token) - module._write_release_recovery(token) + module._write_release_recovery(token, module._owned_drain_started_at_ns(token)) fail_directory_sync_on(1) module.complete_replacement(41, 902, 77, 903, token) elif scenario == "drain-unlink-sync-failure": @@ -346,19 +373,19 @@ try: fail_directory_sync_on(3) module.complete_replacement(41, 902, 77, 903, token) elif scenario == "prepare-recovery-only": - module._write_release_recovery("a" * 32) + module._write_release_recovery("a" * 32, RECOVERY_STARTED_AT_NS) module._load_gateway_modules = forbid_gateway_or_validation module.validate_cron_tree = forbid_gateway_or_validation module.prepare_recovery() elif scenario == "prepare-matching": - module._write_owned_drain("a" * 32) - module._write_release_recovery("a" * 32) + module._write_owned_drain("a" * 32, started_at_ns=RECOVERY_STARTED_AT_NS) + module._write_release_recovery("a" * 32, RECOVERY_STARTED_AT_NS) module._load_gateway_modules = forbid_gateway_or_validation module.validate_cron_tree = forbid_gateway_or_validation module.prepare_recovery() elif scenario == "prepare-matching-sync-failure": - module._write_owned_drain("a" * 32) - module._write_release_recovery("a" * 32) + module._write_owned_drain("a" * 32, started_at_ns=RECOVERY_STARTED_AT_NS) + module._write_release_recovery("a" * 32, RECOVERY_STARTED_AT_NS) module._load_gateway_modules = forbid_gateway_or_validation module.validate_cron_tree = forbid_gateway_or_validation fail_directory_sync_on(1) @@ -372,35 +399,35 @@ try: fail_directory_sync_on(1) module.prepare_recovery() elif scenario == "prepare-mismatch": - module._write_owned_drain("a" * 32) - module._write_release_recovery("b" * 32) + module._write_owned_drain("a" * 32, started_at_ns=RECOVERY_STARTED_AT_NS) + module._write_release_recovery("b" * 32, RECOVERY_STARTED_AT_NS) module._load_gateway_modules = forbid_gateway_or_validation module.validate_cron_tree = forbid_gateway_or_validation module.prepare_recovery() elif scenario == "prepare-recovery-unsafe-mode": - module._write_release_recovery("a" * 32) + module._write_release_recovery("a" * 32, RECOVERY_STARTED_AT_NS) os.chmod(module._release_recovery_path(), 0o600) module.prepare_recovery() elif scenario == "prepare-recovery-symlink": - module._write_release_recovery("a" * 32) + module._write_release_recovery("a" * 32, RECOVERY_STARTED_AT_NS) recovery = module._release_recovery_path() held = module.NEMOCLAW_HOME / "held-recovery.json" recovery.rename(held) recovery.symlink_to(held.name) module.prepare_recovery() elif scenario == "prepare-recovery-hardlink": - module._write_release_recovery("a" * 32) + module._write_release_recovery("a" * 32, RECOVERY_STARTED_AT_NS) os.link( module._release_recovery_path(), module.NEMOCLAW_HOME / "held-recovery.json", ) module.prepare_recovery() elif scenario == "pending-release-recovery": - module._write_release_recovery("a" * 32) + module._write_release_recovery("a" * 32, RECOVERY_STARTED_AT_NS) module.begin_drain() elif scenario == "mismatched-release-recovery": module.begin_drain() - module._write_release_recovery("b" * 32) + module._write_release_recovery("b" * 32, RECOVERY_STARTED_AT_NS) module.recover_drain() elif scenario == "recover-release-rollback": token = module.begin_drain() @@ -428,6 +455,38 @@ try: module._wait_for_release_disposition = original_wait_for_release module._write_owned_drain = original_write_owned_drain module.recover_drain() + elif scenario == "recover-preserves-gate-start": + token = module.begin_drain() + original_started_at = module._owned_drain_started_at(token) + module.validate_restore(41, 902, token) + status.payload["pid"] = 77 + status.payload["start_time"] = 903 + module.observe_replacement(41, 902, token) + original_wait_for_release = module._wait_for_release_disposition + original_write_owned_drain = module._write_owned_drain + def fail_release(*_args, **_kwargs): + raise module.ControlError("simulated replacement release failure") + def fail_rollback(*_args, **_kwargs): + raise module.ControlError("simulated marker rollback failure") + module._wait_for_release_disposition = fail_release + module._write_owned_drain = fail_rollback + try: + module.complete_replacement(41, 902, 77, 903, token) + except module.ControlError as error: + if error.code != module.DRAIN_MARKER_ROLLBACK_FAILED_CODE: + raise + module._emit_control_error(error) + else: + raise AssertionError("release rollback unexpectedly succeeded") + finally: + module._wait_for_release_disposition = original_wait_for_release + module._write_owned_drain = original_write_owned_drain + delayed_one_shot_due_at = datetime.now(timezone.utc) + if delayed_one_shot_due_at <= original_started_at: + raise AssertionError("delayed one-shot time did not follow gate acquisition") + module.recover_drain() + print("ORIGINAL_GATE_START:" + original_started_at.isoformat()) + print("DELAYED_ONESHOT_DUE:" + delayed_one_shot_due_at.isoformat()) elif scenario == "recover": module.begin_drain() status.payload["pid"] = 77 @@ -458,6 +517,7 @@ finally: ) print(f"CRON_VALIDATIONS:{cron_validations}") print(f"DURABILITY_SYNCS:{durability_sync_calls}") + print("REARM_CALLS:" + ",".join(rearm_calls)) if drain.marker is not None: print("FINAL_MARKER:" + drain.marker["principal"]) `; @@ -530,6 +590,7 @@ describe("Hermes in-sandbox cron restore validator", () => { | "complete-release-rollback-failure" | "complete-durable-order" | "release-recovery-sync-failure" + | "rearm-failure" | "existing-recovery-sync-failure" | "drain-unlink-sync-failure" | "recovery-unlink-sync-failure" @@ -546,6 +607,7 @@ describe("Hermes in-sandbox cron restore validator", () => { | "pending-release-recovery" | "mismatched-release-recovery" | "recover-release-rollback" + | "recover-preserves-gate-start" | "recover" | "recover-operator" | "recover-noop", @@ -664,6 +726,8 @@ describe("Hermes in-sandbox cron restore validator", () => { ); expect(result.stdout).toContain("OPERATOR_MUTATIONS:0:0"); expect(result.stdout).toContain("OWN_MARKER:absent"); + expect(result.stdout).toMatch(/REARM_CALLS:[^\n]+/u); + expect(receipts.at(-1)).toMatchObject({ rearmed_oneshots: 1 }); }); it("rejects validation against a different gateway identity", () => { @@ -895,6 +959,16 @@ describe("Hermes in-sandbox cron restore validator", () => { expect(result.stdout).not.toContain('"action":"complete"'); }); + it("keeps dispatch drained when delayed one-shots cannot be re-armed (#8472)", () => { + const result = runLifecycle("rearm-failure"); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Hermes cron restore could not re-arm delayed one-shots"); + expect(result.stdout).toContain("OWN_MARKER:present"); + expect(result.stdout).toContain("RECOVERY_STATE:present"); + expect(result.stdout).not.toContain('"action":"recover"'); + }); + it("rechecks existing recovery-record durability before marker deletion (#8472)", () => { const result = runLifecycle("existing-recovery-sync-failure"); @@ -1077,6 +1151,34 @@ describe("Hermes in-sandbox cron restore validator", () => { expect(result.stdout).toContain("RECOVERY_STATE:absent"); }); + it("retains the original gate time when recovery recreates the drain marker (#8472)", () => { + const result = runLifecycle("recover-preserves-gate-start"); + + expect(result.status).toBe(0); + expect(result.stderr).toContain( + "Hermes cron restore drain release failed and its marker could not be restored", + ); + const originalGateStart = result.stdout.match(/^ORIGINAL_GATE_START:(.+)$/mu)?.[1]; + const rearmCalls = result.stdout.match(/^REARM_CALLS:(.+)$/mu)?.[1].split(","); + expect(originalGateStart).toBeTruthy(); + expect(rearmCalls).toEqual([originalGateStart, originalGateStart]); + const receipts = result.stdout + .split("\n") + .filter((line) => line.startsWith(RECEIPT_PREFIX)) + .map((line) => JSON.parse(line.slice(RECEIPT_PREFIX.length))); + expect(receipts.at(-1)).toEqual( + expect.objectContaining({ + action: "recover", + disposition: "dispatch-reactivated", + rearmed_oneshots: 1, + }), + ); + expect(result.stdout).toContain("ORIGINAL_GATE_START:"); + expect(result.stdout).toContain("DELAYED_ONESHOT_DUE:"); + expect(result.stdout).toContain("OWN_MARKER:absent"); + expect(result.stdout).toContain("RECOVERY_STATE:absent"); + }); + it("re-pins a restarted gateway before validating and reactivating dispatch", () => { const result = runLifecycle("recover"); diff --git a/test/agents/hermes/hermes-cron-restore-drain-patch.test.ts b/test/agents/hermes/hermes-cron-restore-drain-patch.test.ts index d7808c2b3aa..3d354fa48b3 100644 --- a/test/agents/hermes/hermes-cron-restore-drain-patch.test.ts +++ b/test/agents/hermes/hermes-cron-restore-drain-patch.test.ts @@ -9,7 +9,9 @@ import { describe, expect, it } from "vitest"; const PATCHER = path.resolve("agents/hermes/patch-cron-restore-drain.py"); -const DRAIN_SOURCE = `from pathlib import Path +const DRAIN_SOURCE = `import functools +from pathlib import Path +from typing import Optional from utils import atomic_json_write _DRAIN_REQUEST_FILENAME = ".drain_request.json" @@ -29,7 +31,16 @@ def drain_notification_suppressed(*, home: Optional[Path] = None) -> bool: return False `; -const RUN_SOURCE = `class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, GatewaySlashCommandsMixin): +const RUN_SOURCE = `class GatewayAuthorizationMixin: + pass + +class GatewayKanbanWatchersMixin: + pass + +class GatewaySlashCommandsMixin: + pass + +class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, GatewaySlashCommandsMixin): def __init__(self): # External (NAS-driven) drain state — distinct from the shutdown # \`\`_draining\`\` flag above. Set by \`\`_drain_control_watcher\`\` when the @@ -42,14 +53,30 @@ const RUN_SOURCE = `class GatewayRunner(GatewayAuthorizationMixin, GatewayKanban # request -> poll -> proceed loop. self._external_drain_active = False + def _update_runtime_status(self, status): + self.runtime_status = status + def _enter_external_drain(self): if self._external_drain_active: return + + def _exit_external_drain(self): + if not self._external_drain_active: + return + self._external_drain_active = False +`; + +const JOBS_SOURCE = `from datetime import datetime, timedelta +from typing import Any, Dict, List + +def get_due_jobs() -> List[Dict[str, Any]]: + return [] `; interface Fixture { drainControl: string; gatewayRun: string; + cronJobs: string; root: string; } @@ -57,15 +84,26 @@ function createFixture(): Fixture { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cron-drain-patch-")); const drainControl = path.join(root, "drain_control.py"); const gatewayRun = path.join(root, "run.py"); + const cronJobs = path.join(root, "jobs.py"); fs.writeFileSync(drainControl, DRAIN_SOURCE); fs.writeFileSync(gatewayRun, RUN_SOURCE); - return { drainControl, gatewayRun, root }; + fs.writeFileSync(cronJobs, JOBS_SOURCE); + return { drainControl, gatewayRun, cronJobs, root }; } function runPatcher(fixture: Fixture) { return spawnSync( process.env.PYTHON || "python3", - ["-I", PATCHER, "--drain-control", fixture.drainControl, "--gateway-run", fixture.gatewayRun], + [ + "-I", + PATCHER, + "--drain-control", + fixture.drainControl, + "--gateway-run", + fixture.gatewayRun, + "--cron-jobs", + fixture.cronJobs, + ], { encoding: "utf8" }, ); } @@ -74,66 +112,192 @@ describe("Hermes cron restore drain source patch", () => { it("composes independent drains and hydrates the startup gate synchronously", () => { const fixture = createFixture(); try { - const first = runPatcher(fixture); - const firstDrain = fs.readFileSync(fixture.drainControl, "utf8"); - const firstRun = fs.readFileSync(fixture.gatewayRun, "utf8"); - const second = runPatcher(fixture); - - expect(first.status).toBe(0); - expect(first.stderr).toBe(""); - expect(second.status).toBe(0); - expect(second.stderr).toBe(""); - expect(fs.readFileSync(fixture.drainControl, "utf8")).toBe(firstDrain); - expect(fs.readFileSync(fixture.gatewayRun, "utf8")).toBe(firstRun); - expect(firstDrain).toContain('"/sandbox/.nemoclaw/hermes-cron-restore-drain.json"'); - expect(firstDrain).toContain("def operator_drain_requested("); - expect(firstDrain).toContain("def nemoclaw_cron_restore_drain_requested("); - expect(firstDrain).toContain("state_root_fd = os.open(state_root, flags)"); - expect(firstDrain).toContain("metadata = os.fstat(state_root_fd)"); - expect(firstDrain).toContain("dir_fd=state_root_fd"); - expect(firstDrain).toContain("metadata.st_uid != 0"); - expect(firstDrain).toContain("stat.S_IMODE(metadata.st_mode) & 0o022"); - expect(firstDrain).toContain( - "nemoclaw_cron_restore_drain_requested()\n or operator_drain_requested(home=home)", - ); - expect(firstRun).toContain("self._external_drain_active = drain_requested()"); - expect(firstRun).toContain( - 'if self._external_drain_active:\n self._update_runtime_status("draining")', - ); - expect(firstRun).not.toContain("self._external_drain_active = False"); - } finally { - fs.rmSync(fixture.root, { recursive: true, force: true }); - } - }); + const patchResult = runPatcher(fixture); + expect(patchResult.status, patchResult.stderr).toBe(0); + const probe = ` +import importlib.util +import json +import os +import stat +import sys +import types +from pathlib import Path - it("rejects a partially applied two-file patch", () => { - const fixture = createFixture(); - try { - expect(runPatcher(fixture).status).toBe(0); - fs.writeFileSync(fixture.gatewayRun, RUN_SOURCE); +utils = types.ModuleType("utils") +utils.atomic_json_write = lambda *args, **kwargs: None +sys.modules["utils"] = utils + +def load(name, source): + spec = importlib.util.spec_from_file_location(name, source) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + +drain = load("gateway.drain_control", sys.argv[1]) +gateway = types.ModuleType("gateway") +gateway.__path__ = [] +gateway.drain_control = drain +sys.modules["gateway"] = gateway - const result = runPatcher(fixture); +drain.operator_drain_requested = lambda home=None: False +original_open, original_fstat, original_stat, original_close = os.open, os.fstat, os.stat, os.close +os.open = lambda *_args, **_kwargs: 42 +os.fstat = lambda _fd: types.SimpleNamespace(st_mode=stat.S_IFDIR | 0o755, st_uid=0, st_gid=0) +os.close = lambda _fd: None +try: + os.stat = lambda *_args, **_kwargs: (_ for _ in ()).throw(FileNotFoundError()) + absent = drain.drain_requested() + os.stat = lambda *_args, **_kwargs: types.SimpleNamespace() + present = drain.drain_requested() + runner_module = load("patched_gateway_run", sys.argv[2]) + runner = runner_module.GatewayRunner() + runner._enter_external_drain() +finally: + os.open, os.fstat, os.stat, os.close = original_open, original_fstat, original_stat, original_close - expect(result.status).toBe(1); - expect(result.stderr).toContain("patch is only partially applied"); +print(json.dumps({ + "absent": absent, + "present": present, + "startup_active": runner._external_drain_active, + "runtime_status": runner.runtime_status, +})) +`; + const result = spawnSync( + process.env.PYTHON || "python3", + ["-I", "-c", probe, fixture.drainControl, fixture.gatewayRun], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + absent: false, + present: true, + runtime_status: "draining", + startup_active: true, + }); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); } }); - it("fails closed when the pinned drain predicate shape drifts", () => { + it("re-arms eligible one-shots in every profile before the restore gate opens", () => { const fixture = createFixture(); try { - fs.writeFileSync( - fixture.drainControl, - DRAIN_SOURCE.replace(".drain_request.json", ".changed.json"), - ); + const patchResult = runPatcher(fixture); + expect(patchResult.status, patchResult.stderr).toBe(0); + const probe = ` +import contextlib +import importlib.util +import json +from datetime import datetime, timezone + +spec = importlib.util.spec_from_file_location("patched_jobs", ${JSON.stringify(fixture.cronJobs)}) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +now = datetime(2026, 8, 30, 12, 0, 0, tzinfo=timezone.utc) +not_before = datetime(2026, 8, 30, 11, 50, 0, tzinfo=timezone.utc) +default_jobs = [ + {"id": "held", "enabled": True, "state": "scheduled", "last_run_at": None, + "run_claim": None, "fire_claim": None, "repeat": {"completed": 0}, + "schedule": {"kind": "once", "run_at": "2026-08-30T11:55:00+00:00"}, + "next_run_at": "2026-08-30T11:55:00+00:00"}, + {"id": "old", "enabled": True, "state": "scheduled", "last_run_at": None, + "run_claim": None, "fire_claim": None, + "schedule": {"kind": "once", "run_at": "2026-08-30T11:40:00+00:00"}, + "next_run_at": "2026-08-30T11:40:00+00:00"}, + {"id": "future", "enabled": True, "state": "scheduled", "last_run_at": None, + "run_claim": None, "fire_claim": None, + "schedule": {"kind": "once", "run_at": "2026-08-30T12:05:00+00:00"}, + "next_run_at": "2026-08-30T12:05:00+00:00"}, + {"id": "claimed", "enabled": True, "state": "scheduled", "last_run_at": None, + "run_claim": {"by": "other"}, "fire_claim": None, + "schedule": {"kind": "once", "run_at": "2026-08-30T11:55:00+00:00"}, + "next_run_at": "2026-08-30T11:55:00+00:00"}, +] +named_jobs = [ + {"id": "named-held", "enabled": True, "state": "scheduled", "last_run_at": None, + "run_claim": None, "fire_claim": None, "repeat": {"completed": 0}, + "schedule": {"kind": "once", "run_at": "2026-08-30T11:58:00+00:00"}, + "next_run_at": "2026-08-30T11:58:00+00:00"}, + {"id": "named-disabled", "enabled": False, "state": "scheduled", "last_run_at": None, + "run_claim": None, "fire_claim": None, + "schedule": {"kind": "once", "run_at": "2026-08-30T11:58:00+00:00"}, + "next_run_at": "2026-08-30T11:58:00+00:00"}, +] +stores = {"default": default_jobs, "named": named_jobs} +saved = [] +active_home = None - const result = runPatcher(fixture); +@contextlib.contextmanager +def use_cron_store(home): + global active_home + previous = active_home + active_home = home + try: + yield + finally: + active_home = previous - expect(result.status).toBe(1); - expect(result.stderr).toContain("drain predicate is neither wholly"); - expect(fs.readFileSync(fixture.gatewayRun, "utf8")).toBe(RUN_SOURCE); +module._hermes_now = lambda: now +module._ensure_aware = lambda value: value +module.parse_schedule = lambda value: {"kind": "once", "run_at": value, "display": value} +module.compute_next_run = lambda schedule: schedule["run_at"] +module.use_cron_store = use_cron_store +module.load_jobs = lambda: stores[active_home] +module.save_jobs = lambda value: saved.append({"home": active_home, "jobs": json.loads(json.dumps(value))}) +module._jobs_lock = contextlib.nullcontext +changed = module.rearm_nemoclaw_drained_oneshots(not_before, ["default", "named"]) +now = datetime(2026, 8, 30, 12, 0, 5, tzinfo=timezone.utc) +replayed = module.rearm_nemoclaw_drained_oneshots(not_before, ["default", "named"]) +print(json.dumps({"changed": changed, "replayed": replayed, "stores": stores, "saved": saved})) +`; + const result = spawnSync(process.env.PYTHON || "python3", ["-I", "-c", probe], { + encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + const observed = JSON.parse(result.stdout) as { + changed: number; + replayed: number; + stores: Record< + string, + Array<{ id: string; next_run_at: string; nemoclaw_restore_rearm_gate?: string }> + >; + saved: Array<{ home: string }>; + }; + + expect(observed.changed).toBe(2); + expect(observed.replayed).toBe(2); + expect(observed.saved.map(({ home }) => home)).toEqual([ + "default", + "named", + "default", + "named", + ]); + expect(observed.stores.default.find((job) => job.id === "held")?.next_run_at).toBe( + "2026-08-30T12:00:07+00:00", + ); + expect(observed.stores.default.find((job) => job.id === "future")?.next_run_at).toBe( + "2026-08-30T12:05:00+00:00", + ); + expect(observed.stores.default.find((job) => job.id === "old")?.next_run_at).toBe( + "2026-08-30T11:40:00+00:00", + ); + expect(observed.stores.default.find((job) => job.id === "claimed")?.next_run_at).toBe( + "2026-08-30T11:55:00+00:00", + ); + expect(observed.stores.named.find((job) => job.id === "named-held")?.next_run_at).toBe( + "2026-08-30T12:00:07+00:00", + ); + expect( + observed.stores.default.find((job) => job.id === "held")?.nemoclaw_restore_rearm_gate, + ).toBe("2026-08-30T11:50:00+00:00"); + expect( + observed.stores.named.find((job) => job.id === "named-held")?.nemoclaw_restore_rearm_gate, + ).toBe("2026-08-30T11:50:00+00:00"); + expect(observed.stores.named.find((job) => job.id === "named-disabled")?.next_run_at).toBe( + "2026-08-30T11:58:00+00:00", + ); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); } diff --git a/test/agents/hermes/hermes-dependency-review.test.ts b/test/agents/hermes/hermes-dependency-review.test.ts deleted file mode 100644 index 8fd39a566be..00000000000 --- a/test/agents/hermes/hermes-dependency-review.test.ts +++ /dev/null @@ -1,525 +0,0 @@ -// 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 { dockerfileInstructions } from "../../helpers/dockerfile-run-commands"; - -const root = path.join(import.meta.dirname, "../../.."); -const dockerfileBase = fs.readFileSync( - path.join(root, "agents", "hermes", "Dockerfile.base"), - "utf8", -); -const dockerfile = fs.readFileSync(path.join(root, "agents", "hermes", "Dockerfile"), "utf8"); -const config = fs.readFileSync( - path.join(root, "agents", "hermes", "config", "managed-policy.ts"), - "utf8", -); -const manifest = fs.readFileSync(path.join(root, "agents", "hermes", "manifest.yaml"), "utf8"); -const cliAdapter = JSON.parse( - fs.readFileSync(path.join(root, "agents", "hermes", "hermes-cli-adapter-v1.json"), "utf8"), -); -const review = fs.readFileSync( - path.join(root, "internal", "security-reviews", "hermes-0.19.0-dependency-review.md"), - "utf8", -); -const securityDependenciesPatch = fs.readFileSync( - path.join(root, "agents", "hermes", "security-dependencies.patch"), - "utf8", -); -const runtimeBoundariesPatch = fs.readFileSync( - path.join(root, "agents", "hermes", "runtime-boundaries.patch"), - "utf8", -); -const hindsightProbeRequirementsPath = path.join( - root, - "agents", - "hermes", - "hindsight-client-probe-requirements.txt", -); -const hindsightProbeRequirements = fs.readFileSync(hindsightProbeRequirementsPath, "utf8"); - -function arg(name: string): string { - const match = dockerfileBase.match(new RegExp(`^ARG ${name}=(.+)$`, "mu")); - expect(match, `Missing Dockerfile ARG ${name}`).not.toBeNull(); - return match?.[1] ?? ""; -} - -function uvVersionCheckStatus(output: string, expectedVersion: string): number | null { - const dockerfileLines = dockerfileBase.split("\n"); - const installIndex = dockerfileLines.findIndex( - (line) => line.startsWith("RUN pip3 install ") && line.includes('"uv==${UV_VERSION}"'), - ); - expect(installIndex, "Missing Dockerfile uv install command").toBeGreaterThanOrEqual(0); - - const commandLines = dockerfileLines.slice(installIndex); - const commandEndIndex = commandLines.findIndex((line) => !line.endsWith("\\")); - const versionCheckLines = commandLines.slice(1, commandEndIndex + 1); - expect(versionCheckLines, "Missing Dockerfile uv version check").not.toHaveLength(0); - - const script = [ - 'uv() { printf "%s\\n" "$UV_OUTPUT"; }', - "set -e", - ...versionCheckLines.map((line) => line.replace(/^\s*&&\s*/u, "").replace(/\s*\\$/u, "")), - ].join("\n"); - return spawnSync("/bin/sh", ["-c", script], { - env: { ...process.env, UV_OUTPUT: output, UV_VERSION: expectedVersion }, - }).status; -} - -describe("Hermes 0.19.0 dependency review", () => { - it("binds every active source identity to the reviewed release", () => { - expect(arg("HERMES_VERSION")).toBe("v2026.7.20"); - expect(arg("HERMES_SEMVER")).toBe("0.19.0"); - expect(arg("HERMES_TARBALL_SHA256")).toBe( - "285f3fc134ff466a90065e1517801a68993733b807158ee8f32aa01613786990", - ); - expect(arg("HERMES_NPM_INTEGRITY")).toBe( - "sha512-+oVKG3lXbk2kEP+J6BXZjtmSBSaFfczIdOWQ9CUSTdTqq2uyHbk4p+kPyZ6MeGs56JU5qXzMNbqGKRVOQRGC1A==", - ); - expect(manifest).toContain('expected_version: "0.19.0"'); - expect(review).toContain("`3ef6bbd201263d354fd83ec55b3c306ded2eb72a`"); - expect(review).toContain("`bd0bac012aee38a60894781f4597dc29ee7bedb3448540249921f10d3bef327f`"); - expect(review).toContain("`ac986bede64a2785436676c0ea084ec586574f8cb00a9d047e095b435d3e21c0`"); - }); - - it("preserves the reviewed authorization and state migrations", () => { - expect(config).toContain("_config_version: 33"); - expect(config).toMatch(/approvals:\s*\{\s*[\s\S]*?mode: "manual"/u); - expect(config).toMatch(/session_reset:\s*\{\s*[\s\S]*?mode: "both"/u); - expect(config).toMatch(/browser:\s*\{\s*[\s\S]*?restrict_evaluate: true/u); - expect(config).toMatch(/display:\s*\{\s*[\s\S]*?show_reasoning: false/u); - expect(config).toMatch(/display:\s*\{\s*[\s\S]*?show_commentary: false/u); - expect(config).toMatch(/updates:\s*\{\s*[\s\S]*?pre_update_backup: false/u); - expect(config).toMatch(/updates:\s*\{\s*[\s\S]*?refresh_cua_driver: false/u); - expect(manifest).toContain("path: runtime/cron-executions.db\n strategy: sqlite_backup"); - expect(manifest).toContain( - "path: gateway/discord_message_recovery.db\n strategy: sqlite_backup", - ); - expect(review).toContain("mcp__server__tool"); - expect(review).toContain("default-profile"); - expect(review).toContain("named-profile"); - expect(review).toContain("`HERMES-13`"); - expect(review).toContain("`HERMES-14`"); - expect(review).toContain("`HERMES-15`"); - expect(review).toContain("`HERMES-16`"); - expect(review).toContain("`HERMES-17`"); - expect(review).toContain("`HERMES-18`"); - expect(review).toContain("Unresolved upgrade-created high-impact concerns: `0`"); - }); - - it("binds the CLI adapter version and source-fix constraints to target Hermes", () => { - expect(cliAdapter.adapter_version).toBe(1); - expect(cliAdapter.upstream_cli_version).toBe("0.19.0"); - expect(cliAdapter.managed_commands).toEqual(["chat"]); - expect(cliAdapter.session_name_coalescer).toEqual({ - module: "hermes_cli.main", - function: "_coalesce_session_name_args", - boundary_set: "_SUBCOMMANDS", - }); - expect(Object.keys(cliAdapter.translations).sort()).toEqual([ - "provider_model_composition", - "resumed_oneshot", - ]); - expect( - ( - Object.values(cliAdapter.translations) as Array<{ - source_fix_constraint?: unknown; - }> - ).every( - (translation) => - typeof translation.source_fix_constraint === "string" && - translation.source_fix_constraint.length > 0, - ), - ).toBe(true); - }); - - it("accepts uv build metadata and rejects a different semantic version", () => { - const expectedVersion = arg("UV_VERSION"); - const differentVersion = expectedVersion.replace(/\d+$/u, (patch) => - String(Number.parseInt(patch, 10) + 1), - ); - expect( - uvVersionCheckStatus( - `uv ${expectedVersion} (fece32fc5 2026-07-28 aarch64-unknown-linux-gnu)`, - expectedVersion, - ), - ).toBe(0); - expect( - uvVersionCheckStatus(`uv ${differentVersion} (different build metadata)`, expectedVersion), - ).toBe(1); - }); - - it("ships the reviewed Python dependency remediations and records residual debt", () => { - expect(dockerfileBase).toContain( - "COPY agents/hermes/security-dependencies.patch /tmp/hermes-security-dependencies.patch", - ); - expect(dockerfile).toContain( - "COPY agents/hermes/security-dependencies.patch /scripts/hermes-security-dependencies.patch", - ); - expect(dockerfileBase).toContain( - "COPY agents/hermes/runtime-boundaries.patch /tmp/hermes-runtime-boundaries.patch", - ); - expect(dockerfile).toContain( - "COPY agents/hermes/runtime-boundaries.patch /scripts/hermes-runtime-boundaries.patch", - ); - expect(dockerfile).toContain("/scripts/hermes-security-dependencies.patch"); - expect(dockerfileBase).toContain( - "git -C /opt/hermes apply --check /tmp/hermes-security-dependencies.patch", - ); - expect(dockerfileBase).toContain( - "git -C /opt/hermes apply --check /tmp/hermes-runtime-boundaries.patch", - ); - expect(dockerfile).toContain("--include=hermes_cli/memory_setup.py"); - expect(dockerfile).toContain("--include=plugins/memory/hindsight/plugin.yaml"); - expect(dockerfile).toContain( - "grep -Fq 'ensure(\"memory.hindsight\", prompt=False)' /opt/hermes/hermes_cli/memory_setup.py", - ); - expect(dockerfile).toContain( - "grep -Fqx ' - \"hindsight-client==0.6.1\"' /opt/hermes/plugins/memory/hindsight/plugin.yaml", - ); - expect(dockerfile).toContain( - "lazy_deps._venv_pip_install(('/tmp/nemoclaw-hindsight-probe/nemoclaw_lazy_probe-1.0.0-py3-none-any.whl',))", - ); - expect(dockerfileBase).toContain( - "HERMES_LAZY_INSTALL_TARGET=/tmp/nemoclaw-hindsight-client-probe", - ); - expect(dockerfileBase).toContain( - "COPY --chmod=0444 agents/hermes/hindsight-client-probe-requirements.txt /tmp/nemoclaw-hindsight-client-probe-requirements.txt", - ); - expect(dockerfileBase).toContain( - "ADD --chmod=0444 --checksum=sha256:9fdda176ab50f7cec8d7339c6608c148f0cd9ad7e65d9d76192f2db730bc330a https://files.pythonhosted.org/", - ); - expect(dockerfileBase).toContain( - "ADD --chmod=0444 --checksum=sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54 https://files.pythonhosted.org/", - ); - expect(hindsightProbeRequirements).toContain( - "hindsight-client==0.6.1 \\\n --hash=sha256:9fdda176ab50f7cec8d7339c6608c148f0cd9ad7e65d9d76192f2db730bc330a", - ); - expect(hindsightProbeRequirements).toContain( - "aiohttp-retry==2.9.1 \\\n --hash=sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54", - ); - expect(dockerfileBase).toContain(`&& rm -rf \\ - /tmp/nemoclaw-hindsight-client-artifacts \\ - /tmp/nemoclaw-hindsight-client-probe \\ - /tmp/nemoclaw-hindsight-client-cache \\ - /tmp/nemoclaw-hindsight-client-probe-requirements.txt \\ - /sandbox/.hermes/lazy-packages \\ - && install -d -o sandbox -g sandbox -m 0750 /sandbox/.hermes/lazy-packages`); - expect(dockerfileBase).toContain("chmod 0555 /tmp/nemoclaw-hindsight-client-artifacts"); - expect(dockerfileBase).toContain("import hindsight_client, importlib.metadata as m"); - const compatibilityLayer = dockerfileInstructions(dockerfileBase).find( - (instruction) => - instruction.keyword === "RUN" && - instruction.body.includes("nemoclaw-hindsight-client-probe-requirements.txt"), - ); - expect(compatibilityLayer).toBeDefined(); - const compatibilityInstall = compatibilityLayer?.body ?? ""; - expect(compatibilityInstall).toContain("--network=none"); - expect(compatibilityInstall).toContain("--no-deps --no-cache --offline --no-index"); - expect(compatibilityInstall).toContain("--require-hashes"); - expect(compatibilityInstall).not.toContain("ensure('memory.hindsight'"); - expect(compatibilityInstall.indexOf("/usr/local/bin/uv pip install")).toBeLessThan( - compatibilityInstall.indexOf("import hindsight_client"), - ); - expect(dockerfile).not.toContain("state-dir-guard.py"); - expect(dockerfile).not.toContain("normalize-hermes-lazy-package-permissions.py"); - expect(dockerfile).toContain("/opt/hermes/plugins/nemoclaw"); - expect(dockerfile).toContain("hermes-bundled-plugins-only"); - expect(dockerfile).toContain("nemoclaw-hostile-user-plugin"); - expect(dockerfile).toContain("--reuid=gateway --regid=gateway --init-groups"); - expect(dockerfile).toContain( - "install -d -o gateway -g gateway -m 0700 /run/nemoclaw/hermes-gateway-lazy-packages", - ); - expect(dockerfile).toContain("nemoclaw_sandbox_tamper.pth"); - expect(dockerfile).toContain("str(sandbox_target) not in sys.path"); - expect(dockerfile).toContain( - "test ! -r /run/nemoclaw/hermes-gateway-lazy-packages/nemoclaw_lazy_probe/__init__.py", - ); - expect(dockerfile).toContain( - `test "$(stat -c '%U:%G %a' /sandbox/.hermes/lazy-packages)" = "sandbox:sandbox 750"`, - ); - expect(dockerfile).toContain( - `test "$(stat -c '%U:%G %a' /sandbox/.hermes)" = "sandbox:sandbox 3770"`, - ); - expect(dockerfileBase).toContain("uv pip check --python /opt/hermes/.venv/bin/python"); - expect(arg("NODE_VERSION")).toBe("24.18.1"); - expect(arg("UV_VERSION")).toBe("0.11.33"); - expect(arg("NEMOCLAW_HERMES_RUNTIME_BOUNDARIES_PATCH_SHA256")).toBe( - "9e41bed797965990bf7edf214c782c18c04d086a15042415ae7085a507873b1c", - ); - expect(securityDependenciesPatch).toContain('hindsight = ["hindsight-client==0.6.1"]'); - expect(securityDependenciesPatch).not.toContain("hindsight-client==0.8."); - expect(securityDependenciesPatch).toContain('ensure("memory.hindsight", prompt=False)'); - expect(securityDependenciesPatch).toContain('- - "hindsight-client>=0.6.1"'); - expect(securityDependenciesPatch).toContain('+ - "hindsight-client==0.6.1"'); - expect(runtimeBoundariesPatch).toContain("def nemoclaw_managed_gateway_plugins_only()"); - expect(runtimeBoundariesPatch).toContain("nemoclaw_protected_process_control"); - expect(runtimeBoundariesPatch).toContain("nemoclaw_sanitized_installer_env"); - expect(runtimeBoundariesPatch).toContain('uv_bin = "/usr/local/bin/uv"'); - expect(runtimeBoundariesPatch).toContain("cwd=trusted_cwd"); - expect(runtimeBoundariesPatch).toContain("protected: dict[str, str] | None = None"); - expect(runtimeBoundariesPatch).toContain( - 'return Path("/run/nemoclaw/hermes-gateway-lazy-packages")', - ); - expect(runtimeBoundariesPatch).toContain('return Path("/opt/hermes/plugins")'); - expect(runtimeBoundariesPatch).toContain( - 'logger.debug("Managed gateway: user and project plugins disabled")', - ); - expect(runtimeBoundariesPatch).toContain( - "diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py", - ); - expect(runtimeBoundariesPatch).toContain( - "diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py", - ); - expect(runtimeBoundariesPatch).toContain( - "diff --git a/plugins/memory/__init__.py b/plugins/memory/__init__.py", - ); - expect(runtimeBoundariesPatch).toContain( - "diff --git a/plugins/cron_providers/__init__.py b/plugins/cron_providers/__init__.py", - ); - expect(runtimeBoundariesPatch).toContain( - "diff --git a/providers/__init__.py b/providers/__init__.py", - ); - expect(runtimeBoundariesPatch).toContain( - "diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py", - ); - for (const selection of [ - '"aiohttp==3.14.3"', - '"cryptography==50.0.0"', - '"alibabacloud-dingtalk==2.2.54"', - '"mcp==1.28.1"', - '"Pillow==12.3.0"', - '"starlette==1.3.1"', - '"tornado==6.5.7"', - ]) { - expect(securityDependenciesPatch).toContain(selection); - } - const addedPatchLines = securityDependenciesPatch - .split("\n") - .filter((line) => line.startsWith("+") && !line.startsWith("+++")) - .join("\n"); - for (const supersededSelection of [ - '"aiohttp==3.14.1"', - '"cryptography==48.0.1"', - '"alibabacloud-dingtalk==2.2.42"', - ]) { - expect(addedPatchLines).not.toContain(supersededSelection); - } - for (const installedVersion of [ - "'agent-client-protocol': '0.9.0'", - "'aiohttp': '3.14.3'", - "'cryptography': '50.0.0'", - "'mcp': '1.28.1'", - "'pillow': '12.3.0'", - "'starlette': '1.3.1'", - "'tornado': '6.5.7'", - ]) { - expect(dockerfileBase).toContain(installedVersion); - } - expect(dockerfileBase).not.toContain("'aiohttp': '3.14.1'"); - expect(dockerfileBase).not.toContain("'cryptography': '48.0.1'"); - expect(dockerfileBase).toContain("python-multipart==0.0.32"); - expect(dockerfileBase).toContain( - "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", - ); - expect(dockerfileBase).toContain( - "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", - ); - for (const advisory of ["GHSA-5rvq-cxj2-64vf", "GHSA-6jv3-5f52-599m", "GHSA-v9pg-7xvm-68hf"]) { - expect(review).toContain(advisory); - } - for (const advisory of ["GHSA-cq5v-8q36-5273", "GHSA-g6cj-pr64-35w5"]) { - expect(review).toContain(advisory); - } - expect(review).toContain("`aiohttp==3.14.3`"); - expect(review).toContain("`cryptography==50.0.0`"); - expect(review).toContain("`alibabacloud-dingtalk==2.2.54`"); - expect(review).toContain( - "contains 95 unique third-party package names across all retained environment markers", - ); - expect(review).toContain( - "Six exported packages—`colorama`, `concurrent-log-handler`, `portalocker`, `pywin32`, `pywinpty`, and `tzdata`—are guarded by `sys_platform == 'win32'`", - ); - expect(review).toContain( - "both published base jobs prepared, installed, and compatibility-checked 90 distributions", - ); - expect(review).toContain("`agent-client-protocol==0.9.0`"); - expect(review).toContain("PyPI serves no PEP 740 provenance"); - expect(review).toContain("does not validate protocol sessions"); - expect(review).toContain("require separate product acceptance and end-to-end evidence"); - expect(review).toContain("95-package amd64 and arm64 capability-union evidence predates ACP"); - expect(review).toContain( - "ghcr.io/nvidia/nemoclaw/hermes-sandbox@sha256:0d07845fa3b02a0657d28e134eb2e1f4a96cc6260e2538f3d4eafe831c7e5c17", - ); - expect(review).toContain("found 97 installed distributions"); - expect(review).toContain("contains 95 third-party runtime distributions"); - expect(review).toContain("Tornado `6.5.7` is the lowest version"); - expect(review).toContain("source-distribution-only"); - expect(review).toContain("`mcp==1.28.1`"); - expect(review).toContain("`Pillow==12.3.0`"); - expect(review).toContain("`starlette==1.3.1`"); - expect(review).toContain("`tornado==6.5.7`"); - expect(review).toContain("checksum-pinned Node.js `24.18.1`"); - expect(review).toContain("exact uv `0.11.33`"); - }); - - it("rejects an altered Hindsight wheel before the compatibility import", () => { - const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hindsight-hash-")); - const artifact = path.join(temporaryRoot, "hindsight_client-0.6.1-py3-none-any.whl"); - const installTarget = path.join(temporaryRoot, "install"); - fs.writeFileSync(artifact, "same version, altered wheel digest\n", "utf8"); - - try { - const result = spawnSync( - "python3", - [ - "-m", - "pip", - "install", - "--target", - installTarget, - "--no-deps", - "--no-index", - "--find-links", - temporaryRoot, - "--require-hashes", - "-r", - hindsightProbeRequirementsPath, - ], - { encoding: "utf8" }, - ); - const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; - expect(result.status, output).not.toBe(0); - expect(output).toContain("DO NOT MATCH THE HASHES"); - expect(output).toContain( - "Expected sha256 9fdda176ab50f7cec8d7339c6608c148f0cd9ad7e65d9d76192f2db730bc330a", - ); - expect(fs.existsSync(path.join(installTarget, "hindsight_client"))).toBe(false); - } finally { - fs.rmSync(temporaryRoot, { recursive: true, force: true }); - } - }); - - it("keeps the gateway installer offline and isolated from sandbox packages", () => { - const instructions = dockerfileInstructions(dockerfile); - const lazyInstallLayer = instructions.find( - (instruction) => - instruction.keyword === "RUN" && - instruction.body.includes( - "lazy_deps._venv_pip_install(('/tmp/nemoclaw-hindsight-probe/nemoclaw_lazy_probe-1.0.0-py3-none-any.whl',))", - ), - ); - expect(lazyInstallLayer).toBeDefined(); - - const layer = lazyInstallLayer?.body ?? ""; - const orderedContracts = [ - "/opt/hermes/.venv/bin/python -I -m ensurepip --upgrade --default-pip", - "/opt/hermes/.venv/bin/python -I -m pip --version", - "chmod 644 /opt/hermes/.venv/.lock", - `test "$(stat -c '%U:%G %a' /opt/hermes/.venv/.lock)" = "root:root 644"`, - `test "$(stat -c '%U:%G %a' /opt/hermes/.venv/bin/pip)" = "root:root 755"`, - `venv_violation="$(find -P /opt/hermes/.venv ! -type l`, - `test -z "$venv_violation"`, - `venv_link_owner_violation="$(find -P /opt/hermes/.venv -type l`, - `test -z "$venv_link_owner_violation"`, - `venv_links_file="$(mktemp)"`, - `find -P /opt/hermes/.venv -type l -printf '%P -> %l\\n' > "$venv_links_file"`, - `LC_ALL=C sort -o "$venv_links_file" "$venv_links_file"`, - `venv_links="$(cat "$venv_links_file")"`, - `rm -f "$venv_links_file"`, - `expected_venv_links="$(printf '%s\\n'`, - "'bin/python -> /usr/bin/python3'", - "'bin/python3 -> python'", - "'bin/python3.13 -> python'", - `"lib/python3.13/site-packages/certifi/cacert.pem -> $SSL_CERT_FILE"`, - "'lib64 -> lib'", - `test "$venv_links" = "$expected_venv_links"`, - `test "$(readlink -e /opt/hermes/.venv/bin/python)" = "/usr/bin/python3.13"`, - `test "$(readlink -e /opt/hermes/.venv/lib64)" = "/opt/hermes/.venv/lib"`, - `test "$(stat -Lc '%U:%G %a %F' /opt/hermes/.venv/bin/python)" = "root:root 755 regular file"`, - `test "$(stat -Lc '%U:%G %a %F' /opt/hermes/.venv/lib64)" = "root:root 755 directory"`, - "/usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups --", - "sh -eu -c", - "/opt/hermes/.venv/bin/python -I -m pip --version >/dev/null", - `if printf "" >> /opt/hermes/.venv/.lock 2>/dev/null; then exit 1; fi`, - `if printf "" >> /opt/hermes/.venv/bin/pip 2>/dev/null; then exit 1; fi`, - `if printf "" >> /opt/hermes/.venv/lib/python3.13/site-packages/pip/__init__.py 2>/dev/null; then exit 1; fi`, - `if printf "" >> /opt/hermes/.venv/bin/python 2>/dev/null; then exit 1; fi`, - `if printf "" > /opt/hermes/.venv/lib64/.nemoclaw-sandbox-write-probe 2>/dev/null; then exit 1; fi`, - `if ln -sf /usr/bin/false /opt/hermes/.venv/bin/python 2>/dev/null; then exit 1; fi`, - `exit 0`, - `test ! -e /opt/hermes/.venv/lib/.nemoclaw-sandbox-write-probe`, - `chmod 0444 /tmp/nemoclaw-hindsight-probe/nemoclaw_lazy_probe-1.0.0-py3-none-any.whl`, - `install -d -o root -g root -m 0755 /run/nemoclaw`, - `printf '1\\n' > /run/nemoclaw/hermes-bundled-plugins-only`, - `chmod 0444 /run/nemoclaw/hermes-bundled-plugins-only`, - `rm -rf /run/nemoclaw/hermes-gateway-lazy-packages`, - `install -d -o gateway -g gateway -m 0700 /run/nemoclaw/hermes-gateway-lazy-packages`, - `test "$(stat -c '%U:%G %a' /run/nemoclaw/hermes-gateway-lazy-packages)" = "gateway:gateway 700"`, - `rm -rf /sandbox/.hermes/lazy-packages`, - `install -d -o sandbox -g sandbox -m 0750 /sandbox/.hermes/lazy-packages/nemoclaw_lazy_probe`, - "NEMOCLAW_SANDBOX_TAMPER_FIXTURE = True", - "nemoclaw_sandbox_tamper.pth", - `chown -R sandbox:sandbox /sandbox/.hermes/lazy-packages`, - `HERMES_LAZY_INSTALL_TARGET=/run/nemoclaw/hermes-gateway-lazy-packages`, - "/usr/bin/setpriv --reuid=gateway --regid=gateway --init-groups --", - "lazy_deps._venv_pip_install(('/tmp/nemoclaw-hindsight-probe/nemoclaw_lazy_probe-1.0.0-py3-none-any.whl',))", - "assert result.success, result.stderr or result.stdout", - "import hermes_bootstrap, nemoclaw_lazy_probe", - "assert m.version('nemoclaw-lazy-probe') == '1.0.0'", - "assert pathlib.Path(nemoclaw_lazy_probe.__file__).resolve().is_relative_to(gateway_target)", - "assert str(sandbox_target) not in sys.path", - "assert not pathlib.Path('/tmp/nemoclaw-sandbox-lazy-pth-executed').exists()", - `test "$(stat -c '%U:%G' /run/nemoclaw/hermes-gateway-lazy-packages/nemoclaw_lazy_probe/__init__.py)" = "gateway:gateway"`, - `test ! -r /run/nemoclaw/hermes-gateway-lazy-packages/nemoclaw_lazy_probe/__init__.py`, - `/run/nemoclaw/hermes-gateway-lazy-packages/.nemoclaw-sandbox-write-probe`, - `test ! -e /run/nemoclaw/hermes-gateway-lazy-packages/.nemoclaw-sandbox-write-probe`, - `rm -rf /run/nemoclaw/hermes-gateway-lazy-packages`, - `rm -f /run/nemoclaw/hermes-bundled-plugins-only`, - `rm -rf /sandbox/.hermes/lazy-packages`, - `install -d -o sandbox -g sandbox -m 0750 /sandbox/.hermes/lazy-packages`, - `chmod u=rwx,g=rx,o=,g-s /sandbox/.hermes/lazy-packages`, - ]; - let previousIndex = -1; - orderedContracts.forEach((contract) => { - const contractIndex = layer.indexOf(contract, previousIndex + 1); - expect( - contractIndex, - `Missing or misordered lazy-install contract: ${contract}`, - ).toBeGreaterThan(previousIndex); - previousIndex = contractIndex; - }); - expect(layer).toContain("-perm /022"); - expect(layer).not.toContain("--network=none"); - expect(layer).toContain("PIP_NO_INDEX=1"); - expect(layer).toContain("UV_FIND_LINKS=/tmp/nemoclaw-hindsight-probe"); - expect(layer).toContain("UV_OFFLINE=1"); - expect(layer).toContain("NEMOCLAW_BUILD_PROBE_FIXTURE"); - expect(layer.match(/chmod u=rwx,g=rx,o=,g-s \/sandbox\/\.hermes\/lazy-packages/g)).toHaveLength( - 1, - ); - expect(layer.lastIndexOf("rm -rf /sandbox/.cache")).toBeGreaterThan( - layer.indexOf( - "lazy_deps._venv_pip_install(('/tmp/nemoclaw-hindsight-probe/nemoclaw_lazy_probe-1.0.0-py3-none-any.whl',))", - ), - ); - expect(layer).not.toContain("https://"); - expect(layer).not.toContain(`test -z "$(find -P /opt/hermes/.venv`); - expect(layer).not.toContain(`printf ''`); - expect(layer).not.toContain("state-dir-guard"); - - const activeUser = instructions - .filter( - (instruction) => - instruction.keyword === "USER" && - instruction.start < (lazyInstallLayer?.start ?? Number.POSITIVE_INFINITY), - ) - .at(-1); - expect(activeUser?.body.trim()).toBe("root"); - }); -}); diff --git a/test/agents/hermes/hermes-doctor-config-hash.test.ts b/test/agents/hermes/hermes-doctor-config-hash.test.ts deleted file mode 100644 index 7cde1e06f41..00000000000 --- a/test/agents/hermes/hermes-doctor-config-hash.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -// 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 { dockerRunCommandBetween, runDockerShell } from "../../helpers/dockerfile-run-shell"; - -const ROOT = path.resolve(import.meta.dirname, "../../.."); -const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); -const HERMES_BUILD_MCP_DIGEST = path.join(ROOT, "agents", "hermes", "build-mcp-digest.py"); -const HERMES_RUNTIME_CONFIG_GUARD = path.join(ROOT, "agents", "hermes", "runtime-config-guard.py"); - -function writeYamlStubPython(root: string): string { - const bootstrap = path.join(root, "python-yaml-bootstrap.py"); - const wrapper = path.join(root, "python-with-yaml-stub"); - fs.writeFileSync( - bootstrap, - String.raw`import json -import runpy -import sys -import types - -yaml = types.ModuleType("yaml") -class YAMLError(Exception): - pass -yaml.YAMLError = YAMLError -def safe_load(text): - try: - parsed = json.loads(text) - except json.JSONDecodeError as exc: - raise YAMLError("fixture must contain valid JSON-compatible YAML") from exc - if not isinstance(parsed, dict) or not isinstance(parsed.get("mcp_servers"), dict): - raise YAMLError("fixture must contain an mcp_servers mapping") - return parsed -yaml.safe_load = safe_load -sys.modules["yaml"] = yaml - -script, *args = sys.argv[1:] -sys.argv = [script, *args] -runpy.run_path(script, run_name="__main__") -`, - ); - fs.writeFileSync( - wrapper, - `#!/usr/bin/env bash\nset -euo pipefail\n[[ "\${1:-}" != "-I" ]] || shift\nexec python3 -I ${JSON.stringify(bootstrap)} "$@"\n`, - { mode: 0o700 }, - ); - return wrapper; -} - -describe("Hermes doctor and config hash boundary", () => { - it("detects a remaining session preview patcher during Hermes upgrades (#5254)", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-preview-guard-")); - const hermesBin = path.join(tmp, "usr", "local", "bin", "hermes"); - const wrapper = path.join(tmp, "usr", "local", "lib", "nemoclaw", "hermes-wrapper.py"); - const previewPatcher = path.join( - tmp, - "usr", - "local", - "lib", - "nemoclaw", - "patch-hermes-session-list-preview.py", - ); - const command = dockerRunCommandBetween( - dockerfile, - 'RUN hermes_version_output="$(/usr/local/bin/hermes --version)"', - "# Validate the versioned adapter", - ) - .replaceAll("/usr/local/bin/hermes", hermesBin) - .replaceAll("/usr/local/lib/nemoclaw/hermes-wrapper.py", wrapper) - .replaceAll("/usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py", previewPatcher); - try { - fs.mkdirSync(path.dirname(hermesBin), { recursive: true }); - fs.mkdirSync(path.dirname(wrapper), { recursive: true }); - fs.writeFileSync(hermesBin, "#!/usr/bin/env bash\nprintf 'hermes v0.20.0\\n'\n", { - mode: 0o755, - }); - fs.writeFileSync(wrapper, "# wrapper fixture without resumed oneshot marker\n"); - fs.writeFileSync(previewPatcher, "EXPECTED_OCCURRENCES = 6\n"); - - const result = spawnSync("bash", ["-c", ["set -euo pipefail", command].join("\n")], { - encoding: "utf-8", - cwd: tmp, - timeout: 5000, - }); - - expect(result.status).toBe(1); - expect(result.stderr).toContain( - "installed Hermes 0.20.0 but Hermes v0.19.0 compatibility workarounds are still installed", - ); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("keeps upstream doctor changes out of generated config hash inputs", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-doctor-lock-")); - const sandboxRoot = path.join(tmp, "sandbox"); - const hermesDir = path.join(sandboxRoot, ".hermes"); - const configPath = path.join(hermesDir, "config.yaml"); - const envPath = path.join(hermesDir, ".env"); - const fakeHermes = path.join(tmp, "hermes"); - const orderLogPath = path.join(tmp, "doctor-generate-order.log"); - const etcDir = path.join(tmp, "etc", "nemoclaw"); - const hermesPython = writeYamlStubPython(tmp); - const mode = (entry: string) => (fs.statSync(entry).mode & 0o777).toString(8); - const generatedConfig = JSON.stringify({ - model: "trusted", - custom_providers: [], - mcp_servers: { - fixture: { command: "/bin/true", args: [] }, - }, - }); - const fakeGenerateCommand = [ - `printf 'generate\\n' >>${JSON.stringify(orderLogPath)}`, - `printf '%s\\n' ${JSON.stringify(generatedConfig)} >${JSON.stringify(configPath)}`, - `printf 'API_SERVER_HOST=127.0.0.1\\nAPI_SERVER_PORT=18642\\n' >${JSON.stringify(envPath)}`, - `chmod 600 ${JSON.stringify(configPath)} ${JSON.stringify(envPath)}`, - ].join("; "); - fs.mkdirSync(hermesDir, { recursive: true }); - fs.writeFileSync(configPath, "model: test\n", { mode: 0o600 }); - fs.writeFileSync(envPath, "TOKEN=test\n", { mode: 0o600 }); - fs.writeFileSync( - fakeHermes, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `test "\${HERMES_HOME:-}" = ${JSON.stringify(hermesDir)}`, - 'test "${1:-} ${2:-}" = "doctor --fix"', - `printf 'doctor\\n' >>${JSON.stringify(orderLogPath)}`, - `printf 'doctor_migrated: true\\n' >>${JSON.stringify(configPath)}; printf 'DOCTOR_MIGRATED=1\\n' >>${JSON.stringify(envPath)}; chmod 666 ${JSON.stringify(configPath)} ${JSON.stringify(envPath)}`, - ].join("\n"), - { mode: 0o700 }, - ); - - const doctorAndGenerateCommand = dockerRunCommandBetween( - dockerfile, - "# Run Hermes' upstream repair", - "# Install the generated policy manifest outside the mutable Hermes home", - ) - .replaceAll("/sandbox", sandboxRoot) - .replaceAll("/usr/local/bin/hermes", fakeHermes) - .replaceAll( - "node --experimental-strip-types /opt/nemoclaw-hermes-config/generate-config.ts", - fakeGenerateCommand, - ); - const lockCommand = dockerRunCommandBetween( - dockerfile, - "# Flatten stale published base images", - "# Pin config hash at build time", - ).replaceAll("/root/.cache/pip", path.join(tmp, "root-cache", "pip")); - const hashCommand = dockerRunCommandBetween( - dockerfile, - "# Pin config hash at build time", - "# Publish the mutable in-tree compatibility hash", - ) - .replaceAll("/etc/nemoclaw", etcDir) - .replaceAll("/opt/hermes/.venv/bin/python", JSON.stringify(hermesPython)) - .replaceAll( - "/usr/local/lib/nemoclaw/build-hermes-mcp-digest.py", - JSON.stringify(HERMES_BUILD_MCP_DIGEST), - ) - .replaceAll( - "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py", - JSON.stringify(HERMES_RUNTIME_CONFIG_GUARD), - ); - const compatHashCommand = dockerRunCommandBetween( - dockerfile, - "# Publish the mutable in-tree compatibility hash", - "# Keep the shared NemoClaw state root", - ).replaceAll("/etc/nemoclaw", etcDir); - - try { - const doctorAndGenerate = spawnSync("bash", ["-c", doctorAndGenerateCommand], { - encoding: "utf-8", - cwd: tmp, - timeout: 5000, - }); - expect(doctorAndGenerate.status).toBe(0); - expect(fs.readFileSync(orderLogPath, "utf-8")).toBe("doctor\ngenerate\n"); - expect([mode(configPath), mode(envPath)]).toEqual(["600", "600"]); - expect(fs.readFileSync(configPath, "utf-8")).not.toContain("doctor_migrated"); - expect(fs.readFileSync(envPath, "utf-8")).not.toContain("DOCTOR_MIGRATED"); - - const lock = runDockerShell(lockCommand, sandboxRoot); - expect(lock.result.status, lock.result.stderr).toBe(0); - expect(lock.result.stderr).toBe(""); - expect([mode(configPath), mode(envPath)]).toEqual(["640", "640"]); - - const hash = runDockerShell(hashCommand, sandboxRoot); - 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")], { - encoding: "utf-8", - timeout: 5000, - }); - expect(verifyHash.status).toBe(0); - - const compatHash = runDockerShell(compatHashCommand, sandboxRoot); - expect(compatHash.result.status).toBe(0); - expect(compatHash.result.stderr).toBe(""); - expect(mode(path.join(hermesDir, ".config-hash"))).toBe("640"); - const verifyCompatHash = spawnSync( - "sha256sum", - ["-c", path.join(hermesDir, ".config-hash")], - { encoding: "utf-8", timeout: 5000 }, - ); - expect(verifyCompatHash.status).toBe(0); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); -}); diff --git a/test/agents/hermes/hermes-env-secret-boundary-hardening.test.ts b/test/agents/hermes/hermes-env-secret-boundary-hardening.test.ts index 8b072cb1219..8ae598368d2 100644 --- a/test/agents/hermes/hermes-env-secret-boundary-hardening.test.ts +++ b/test/agents/hermes/hermes-env-secret-boundary-hardening.test.ts @@ -183,6 +183,29 @@ function runRuntimeEnvValidationAsGateway(lazyTarget: string) { ); } +function runManagedGatewayEnvValidation(envOverrides: Record) { + const environment: Record = { + PATH: "/usr/bin", + HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages", + ...envOverrides, + }; + return spawnSync( + "python3", + [ + "-I", + "-c", + [ + "import json, runpy, sys", + "module = runpy.run_path(sys.argv[1], run_name='nemoclaw_managed_gateway_env_test')", + "raise SystemExit(module['validate_managed_gateway_env'](json.loads(sys.argv[2])))", + ].join("; "), + VALIDATOR, + JSON.stringify(environment), + ], + { encoding: "utf-8", timeout: 5000 }, + ); +} + describe("Hermes env secret-boundary resource limits", () => { it("accepts the normal 0640 mutable env-file mode", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-mode-")); @@ -549,6 +572,28 @@ describe("Hermes durable lazy-install target", () => { expect(refused.stderr).toContain("HERMES_LAZY_INSTALL_TARGET"); }); + it("uses launcher-owned paths when the supervisor environment predates shell exports", () => { + const result = runManagedGatewayEnvValidation({}); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + }); + + it.each([ + { + case: "runtime path control", + key: "HERMES_CONFIG", + value: "/sandbox/hostile-config.yaml", + }, + { case: "raw credential", key: "AWS_SECRET_ACCESS_KEY", value: "raw-secret-value" }, + ])("rejects a $case after applying launcher-owned paths", ({ key, value }) => { + const result = runManagedGatewayEnvValidation({ [key]: value }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(key); + expect(result.stderr).not.toContain(value); + }); + it.each([ ["missing", undefined], ["overridden", "/tmp/untrusted-packages"], diff --git a/test/agents/hermes/hermes-final-image-layout.test.ts b/test/agents/hermes/hermes-final-image-layout.test.ts index c27ac24adf3..909eb4c6572 100644 --- a/test/agents/hermes/hermes-final-image-layout.test.ts +++ b/test/agents/hermes/hermes-final-image-layout.test.ts @@ -1,98 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { createHash } from "node:crypto"; +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 { dockerRunCommandBetween, runDockerShell } from "../../helpers/dockerfile-run-shell"; const ROOT = path.resolve(import.meta.dirname, "../../.."); -const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); -const NPM_ROOT_ARGUMENTS = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; -const HERMES_INTEGRITY_FILES = [ - { - arg: "NEMOCLAW_HERMES_IMAGE_BUILD_PROBES_SHA256", - source: "agents/hermes/image-build-probes.py", - target: "/opt/nemoclaw-hermes-config/image-build-probes.py", - }, - { - arg: "NEMOCLAW_HERMES_SQLITE_TEMP_STORE_PATCHER_SHA256", - source: "agents/hermes/patch-hermes-sqlite-temp-store.py", - target: "/usr/local/lib/nemoclaw/patch-hermes-sqlite-temp-store.py", - }, - { - arg: "NEMOCLAW_HERMES_WRAPPER_SHA256", - source: "agents/hermes/hermes-wrapper.py", - target: "/usr/local/lib/nemoclaw/hermes-wrapper.py", - }, - { - arg: "NEMOCLAW_HERMES_CLI_ADAPTER_SHA256", - source: "agents/hermes/hermes-cli-adapter-v1.json", - target: "/usr/local/share/nemoclaw/hermes-cli-adapter-v1.json", - }, - { - arg: "NEMOCLAW_HERMES_CLI_ADAPTER_VALIDATOR_SHA256", - source: "agents/hermes/validate-cli-adapter.py", - target: "/usr/local/lib/nemoclaw/validate-hermes-cli-adapter.py", - }, - { - arg: "NEMOCLAW_HERMES_VALIDATOR_SHA256", - source: "agents/hermes/validate-env-secret-boundary.py", - target: "/usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py", - }, - { - arg: "NEMOCLAW_HERMES_TIRITH_FINALIZER_SHA256", - source: "agents/hermes/finalize-tirith-marker.py", - target: "/usr/local/lib/nemoclaw/finalize-tirith-marker.py", - }, - { - arg: "NEMOCLAW_HERMES_LANGFUSE_PATCHER_SHA256", - source: "agents/hermes/patch-langfuse-credentials.mts", - target: "/usr/local/lib/nemoclaw/patch-hermes-langfuse-credentials.mts", - }, - { - arg: "NEMOCLAW_HERMES_DISCORD_RECOVERY_PATCHER_SHA256", - source: "agents/hermes/patch-discord-recovery-permissions.py", - target: "/usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py", - }, - { - arg: "NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256", - source: "agents/hermes/patch-profile-policy-defaults.py", - target: "/usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py", - }, - { - arg: "NEMOCLAW_HERMES_GATEWAY_RUNTIME_METADATA_PATCHER_SHA256", - source: "agents/hermes/patch-gateway-runtime-metadata.py", - target: "/opt/nemoclaw-hermes-config/patch-gateway-runtime-metadata.py", - }, - { - arg: "NEMOCLAW_HERMES_GATEWAY_PROCESS_IDENTITY_PATCHER_SHA256", - source: "agents/hermes/patch-gateway-process-identity.py", - target: "/opt/nemoclaw-hermes-config/patch-gateway-process-identity.py", - }, - { - arg: "NEMOCLAW_HERMES_CRON_RUNTIME_PATCHER_SHA256", - source: "agents/hermes/patch-cron-execution-runtime.py", - target: "/opt/nemoclaw-hermes-config/patch-cron-execution-runtime.py", - }, - { - arg: "NEMOCLAW_HERMES_CRON_RESTORE_DRAIN_PATCHER_SHA256", - source: "agents/hermes/patch-cron-restore-drain.py", - target: "/opt/nemoclaw-hermes-config/patch-cron-restore-drain.py", - }, - { - arg: "NEMOCLAW_HERMES_CRON_RESTORE_CONTROLLER_SHA256", - source: "agents/hermes/cron-restore-control.py", - target: "/usr/local/lib/nemoclaw/hermes-cron-restore-control.py", - }, - { - arg: "NEMOCLAW_HERMES_NEUTRAL_PLATFORM_PATCHER_SHA256", - source: "agents/hermes/patch-neutral-platform-env-activation.py", - target: "/opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py", - }, -] as const; +const FINALIZE_IMAGE_LAYOUT = path.join(ROOT, "agents", "hermes", "finalize-image-layout.sh"); type LegacyDataFixture = | "none" @@ -158,12 +74,6 @@ function readText(filePath: string): string { return fs.readFileSync(filePath, "utf-8"); } -function indexOfRequired(haystack: string, needle: string): number { - const index = haystack.indexOf(needle); - expect(index).toBeGreaterThanOrEqual(0); - return index; -} - function runFinalLayout({ legacyData = "none", openclaw = "none", @@ -171,7 +81,6 @@ function runFinalLayout({ legacyData?: LegacyDataFixture; openclaw?: OpenClawFixture; } = {}) { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-final-layout-")); const sandboxRoot = path.join(tmp, "sandbox"); const hermesDir = path.join(sandboxRoot, ".hermes"); @@ -194,38 +103,23 @@ function runFinalLayout({ legacyDataSetups[legacyData](fixturePaths); openclawSetups[openclaw](fixturePaths); - const layoutCommand = dockerRunCommandBetween( - dockerfile, - "# Flatten stale published base images", - "# Pin config hash at build time", - ).replaceAll("/root/.cache/pip", path.join(tmp, "root-cache", "pip")); - const { result } = runDockerShell(layoutCommand, sandboxRoot); + const result = spawnSync("bash", [FINALIZE_IMAGE_LAYOUT, sandboxRoot], { + encoding: "utf-8", + timeout: 5000, + }); return { hermesDir, legacyTarget, openclawTarget, result, sandboxRoot, tmp }; } describe("Hermes final image layout", () => { - // source-shape-contract: security -- Every security-critical Hermes source must match the reviewed Dockerfile digest before image construction proceeds. - it.each(HERMES_INTEGRITY_FILES)( - "pins $source to its current bytes at $target", - ({ arg, source, target }) => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); - const declarationPrefix = `ARG ${arg}=`; - const declarations = dockerfile - .split("\n") - .filter((line) => line.startsWith(declarationPrefix)); - expect(declarations, arg).toHaveLength(1); - - const integrityChecks = dockerfile - .split("\n") - .filter((line) => line.trim() === `"$${arg}" ${target} \\`); - expect(integrityChecks, `${arg} must verify ${target}`).toHaveLength(1); - - const digest = createHash("sha256") - .update(fs.readFileSync(path.join(ROOT, source))) - .digest("hex"); - expect(declarations[0]?.slice(declarationPrefix.length), source).toBe(digest); - }, - ); + it("rejects the filesystem root as the image layout root", () => { + const result = spawnSync("bash", [FINALIZE_IMAGE_LAYOUT, "/"], { + encoding: "utf-8", + timeout: 5000, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("image layout root must not be /"); + }); it("rejects retired OpenClaw state represented as a directory", () => { const run = runFinalLayout({ openclaw: "directory" }); diff --git a/test/agents/hermes/hermes-gateway-wrapper.test.ts b/test/agents/hermes/hermes-gateway-wrapper.test.ts index f026f494f72..56c493ee1d7 100644 --- a/test/agents/hermes/hermes-gateway-wrapper.test.ts +++ b/test/agents/hermes/hermes-gateway-wrapper.test.ts @@ -93,11 +93,15 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { }); it("allows `gateway` when only resolver placeholders / allow-listed keys are present", () => { - const run = runWrapper(["gateway", "run"], { - SLACK_BOT_TOKEN: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", - TELEGRAM_BOT_TOKEN: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", - OPENCLAW_GATEWAY_TOKEN: "raw-gateway-token", - }); + const run = runWrapper( + ["gateway", "run"], + { + SLACK_BOT_TOKEN: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + TELEGRAM_BOT_TOKEN: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + OPENCLAW_GATEWAY_TOKEN: "raw-gateway-token", + }, + { validatorScript: "raise SystemExit(0)\n" }, + ); expect(run.status).toBe(0); expect(run.stderr).toBe(""); @@ -133,6 +137,9 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { expect(run.realInvoked).toBe(true); expect(run.realEnv.HERMES_HOME).toBe("/sandbox/.hermes"); expect(run.realEnv.HERMES_BUNDLED_PLUGINS).toBe("/opt/hermes/plugins"); + expect(run.realEnv.HERMES_LAZY_INSTALL_TARGET).toBe( + "/run/nemoclaw/hermes-gateway-lazy-packages", + ); expect(run.realEnv.HOME).toBe("/sandbox"); const packageEnvironment = Object.fromEntries( Object.entries(run.realEnv).filter( @@ -157,32 +164,32 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py", () => { }); }); - it("leaves package-manager inputs unchanged for a same-identity gateway", () => { - const run = runWrapper(["gateway", "run"], { - UV_CONFIG_FILE: "/sandbox/uv.toml", - PIP_CONFIG_FILE: "/sandbox/pip.conf", - PYTHONPATH: "/sandbox/python", - }); + it.each([ + ["sandbox-owned", "/sandbox/.hermes/lazy-packages"], + ["arbitrary", "/tmp/attacker-controlled-python"], + ])("replaces a %s lazy target before gateway validation and exec", (_label, lazyTarget) => { + const run = runWrapper( + ["gateway", "run"], + { + HERMES_LAZY_INSTALL_TARGET: lazyTarget, + UV_CONFIG_FILE: "/sandbox/uv.toml", + PIP_CONFIG_FILE: "/sandbox/pip.conf", + PYTHONPATH: "/sandbox/python", + }, + { validatorScript: "raise SystemExit(0)\n" }, + ); expect(run.status, run.stderr).toBe(0); + expect(run.realInvoked).toBe(true); expect(run.realEnv.HERMES_HOME).toBe("/sandbox/.hermes"); expect(run.realEnv.HERMES_BUNDLED_PLUGINS).toBe("/opt/hermes/plugins"); + expect(run.realEnv.HERMES_LAZY_INSTALL_TARGET).toBe( + "/run/nemoclaw/hermes-gateway-lazy-packages", + ); expect(run.realEnv.HOME).toBe("/sandbox"); - expect(run.realEnv.UV_CONFIG_FILE).toBe("/sandbox/uv.toml"); - expect(run.realEnv.PIP_CONFIG_FILE).toBe("/sandbox/pip.conf"); - expect(run.realEnv.PYTHONPATH).toBe("/sandbox/python"); - }); - - it("rejects an arbitrary gateway lazy target without exposing its value", () => { - const arbitraryTarget = "/tmp/attacker-controlled-python"; - const run = runWrapper(["gateway", "run"], { - HERMES_LAZY_INSTALL_TARGET: arbitraryTarget, - }); - - expect(run.status).toBe(1); - expect(run.stderr).toContain("HERMES_LAZY_INSTALL_TARGET"); - expect(run.stderr).not.toContain(arbitraryTarget); - expect(run.realInvoked).toBe(false); + expect(run.realEnv.UV_CONFIG_FILE).toBeUndefined(); + expect(run.realEnv.PIP_CONFIG_FILE).toBe("/dev/null"); + expect(run.realEnv.PYTHONPATH).toBeUndefined(); }); it("refuses a direct root gateway before invoking the real binary", () => { diff --git a/test/agents/hermes/hermes-image-build-probes.test.ts b/test/agents/hermes/hermes-image-build-probes.test.ts index bd3b33551e1..64597db7e80 100644 --- a/test/agents/hermes/hermes-image-build-probes.test.ts +++ b/test/agents/hermes/hermes-image-build-probes.test.ts @@ -2,15 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 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 { dockerfileInstructions } from "../../../src/lib/onboard/dockerfile-tool-disclosure-contract"; const root = path.join(import.meta.dirname, "../../.."); -const dockerfile = fs.readFileSync(path.join(root, "agents", "hermes", "Dockerfile"), "utf8"); const probes = path.join(root, "agents", "hermes", "image-build-probes.py"); +const dockerfile = fs.readFileSync(path.join(root, "agents", "hermes", "Dockerfile"), "utf8"); +const a2aNeutralPatch = fs.readFileSync(path.join(root, "agents", "hermes", "a2a-neutral.patch")); const probeSource = fs.readFileSync(probes, "utf8"); const imageProbePath = "/opt/nemoclaw-hermes-config/image-build-probes.py"; const commands = [ @@ -33,9 +36,317 @@ const commands = [ "session-preview", "session-state-create", "session-state-reopen", + "secure-directory-modes", ] as const; +function writeExecutable(target: string, source: string): void { + fs.writeFileSync(target, source, { mode: 0o755 }); +} + +function runCompatibilityRetirementProbe({ + version, + adapter = '{"commands":["resumed_oneshot"]}\n', + oneshot = "process_registry.wait_for_pending_completions(oneshot_task_id)\n", +}: { + version: string; + adapter?: string; + oneshot?: string; +}) { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-compat-probe-")); + const hermes = path.join(temporaryRoot, "hermes"); + const adapterPath = path.join(temporaryRoot, "adapter.json"); + const oneshotPath = path.join(temporaryRoot, "oneshot.py"); + writeExecutable(hermes, `#!/usr/bin/env bash\nprintf '%s\\n' ${JSON.stringify(version)}\n`); + fs.writeFileSync(adapterPath, adapter); + fs.writeFileSync(oneshotPath, oneshot); + const source = ` +import importlib.util +import pathlib +import sys + +spec = importlib.util.spec_from_file_location("image_build_probes", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +module.verify_compatibility_retirement( + hermes=pathlib.Path(sys.argv[2]), + adapter=pathlib.Path(sys.argv[3]), + oneshot=pathlib.Path(sys.argv[4]), +) +`; + try { + return spawnSync("python3", ["-I", "-c", source, probes, hermes, adapterPath, oneshotPath], { + encoding: "utf8", + timeout: 5000, + }); + } finally { + fs.rmSync(temporaryRoot, { force: true, recursive: true }); + } +} + +function runGeneratedConfigPreparation(doctorExit = 0) { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-config-prepare-")); + const hermesHome = path.join(temporaryRoot, ".hermes"); + const hermes = path.join(temporaryRoot, "hermes"); + const node = path.join(temporaryRoot, "node"); + const generator = path.join(temporaryRoot, "generate-config.ts"); + const orderLog = path.join(temporaryRoot, "order.log"); + fs.mkdirSync(hermesHome); + fs.writeFileSync(generator, "// fixture\n"); + writeExecutable( + hermes, + `#!/usr/bin/env bash +set -euo pipefail +test "$*" = "doctor --fix" +printf 'doctor\n' >> "$ORDER_LOG" +printf 'doctor_migrated: true\n' > "$HERMES_HOME/config.yaml" +printf 'DOCTOR_MIGRATED=1\n' > "$HERMES_HOME/.env" +exit ${doctorExit} +`, + ); + writeExecutable( + node, + `#!/usr/bin/env bash +set -euo pipefail +test "$1" = "--experimental-strip-types" +printf 'generate\n' >> "$ORDER_LOG" +printf 'model: trusted\n' > "$HERMES_HOME/config.yaml" +printf 'SAFE=1\n' > "$HERMES_HOME/.env" +chmod 600 "$HERMES_HOME/config.yaml" "$HERMES_HOME/.env" +`, + ); + const source = ` +import importlib.util +import os +import pathlib +import sys + +spec = importlib.util.spec_from_file_location("image_build_probes", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +module.prepare_generated_config( + hermes=pathlib.Path(sys.argv[2]), + node=pathlib.Path(sys.argv[3]), + generator=pathlib.Path(sys.argv[4]), + hermes_home=pathlib.Path(sys.argv[5]), + env={"PATH": os.environ["PATH"], "ORDER_LOG": sys.argv[6]}, +) +`; + const result = spawnSync( + "python3", + ["-I", "-c", source, probes, hermes, node, generator, hermesHome, orderLog], + { encoding: "utf8", timeout: 5000 }, + ); + return { hermesHome, orderLog, result, temporaryRoot }; +} + +function runNeutralPlatformProbe(configuration: string) { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-platform-probe-")); + const gatewayRoot = path.join(temporaryRoot, "gateway"); + fs.mkdirSync(gatewayRoot); + fs.writeFileSync(path.join(gatewayRoot, "__init__.py"), ""); + fs.writeFileSync(path.join(gatewayRoot, "config.py"), configuration); + try { + return spawnSync( + "python3", + [ + "-I", + "-c", + "import importlib.util, sys; " + + "sys.path.insert(0, sys.argv[2]); " + + "spec = importlib.util.spec_from_file_location('image_build_probes', sys.argv[1]); " + + "module = importlib.util.module_from_spec(spec); " + + "spec.loader.exec_module(module); " + + "module.verify_neutral_platform_inertness()", + probes, + temporaryRoot, + ], + { encoding: "utf8", timeout: 5000 }, + ); + } finally { + fs.rmSync(temporaryRoot, { force: true, recursive: true }); + } +} + describe("Hermes image build probes", () => { + it("verifies the A2A neutralization patch before root applies it", () => { + const digest = createHash("sha256").update(a2aNeutralPatch).digest("hex"); + const digestBinding = `ARG NEMOCLAW_HERMES_A2A_NEUTRAL_PATCH_SHA256=${digest}`; + const integrityCheck = + '"$NEMOCLAW_HERMES_A2A_NEUTRAL_PATCH_SHA256" /opt/nemoclaw-hermes-config/a2a-neutral.patch'; + const shaCheck = "| sha256sum -c -"; + const applyCheck = "git -C /opt/hermes apply --check"; + const integrityCheckIndex = dockerfile.indexOf(integrityCheck); + const shaCheckIndex = dockerfile.indexOf(shaCheck, integrityCheckIndex); + + expect(dockerfile).toContain(digestBinding); + expect(integrityCheckIndex).toBeGreaterThan(dockerfile.indexOf(digestBinding)); + expect(shaCheckIndex).toBeGreaterThan(integrityCheckIndex); + expect(dockerfile.indexOf(applyCheck, shaCheckIndex)).toBeGreaterThan(shaCheckIndex); + }); + + // source-shape-contract: security -- The final image must execute the reviewed runtime environment validator bytes + it("binds the runtime environment validator to its source digest", () => { + const imageDockerfile = fs.readFileSync( + path.join(process.cwd(), "agents", "hermes", "Dockerfile"), + "utf8", + ); + const runtimeEnvValidator = fs.readFileSync( + path.join(process.cwd(), "agents", "hermes", "validate-env-secret-boundary.py"), + ); + const digest = createHash("sha256").update(runtimeEnvValidator).digest("hex"); + const digestBinding = `ARG NEMOCLAW_HERMES_VALIDATOR_SHA256=${digest}`; + const integrityCheck = + '"$NEMOCLAW_HERMES_VALIDATOR_SHA256" /usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py'; + const bindingIndex = imageDockerfile.indexOf(digestBinding); + const integrityCheckIndex = imageDockerfile.indexOf(integrityCheck, bindingIndex); + + expect(bindingIndex).toBeGreaterThan(-1); + expect(integrityCheckIndex).toBeGreaterThan(bindingIndex); + expect(imageDockerfile.indexOf("| sha256sum -c -", integrityCheckIndex)).toBeGreaterThan( + integrityCheckIndex, + ); + }); + + it("removes the Hindsight probe wheel after staging its temporary copy", () => { + const probeWheel = "/opt/nemoclaw-hermes-config/hindsight-probe-aiohttp-retry.whl"; + const copyIndex = dockerfile.indexOf(`cp ${probeWheel}`); + const removalIndex = dockerfile.indexOf(`rm ${probeWheel}`, copyIndex); + const absenceCheckIndex = dockerfile.indexOf(`check_absent ${probeWheel}`); + + expect(copyIndex).toBeGreaterThan(-1); + expect(removalIndex).toBeGreaterThan(copyIndex); + expect(absenceCheckIndex).toBeGreaterThan(removalIndex); + }); + + it.each([ + { + digest: "$NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256", + invocation: + "/usr/bin/python3 -I /opt/nemoclaw-hermes-config/patch-profile-policy-defaults.py", + name: "profile policy", + }, + { + digest: "$NEMOCLAW_HERMES_NEUTRAL_PLATFORM_PATCHER_SHA256", + invocation: + "/usr/bin/python3 -I /opt/nemoclaw-hermes-config/patch-neutral-platform-env-activation.py", + name: "neutral platform", + }, + ])("keeps $name patch verification and application in one layer", ({ digest, invocation }) => { + const verificationLayer = dockerfileInstructions(dockerfile).find( + ({ text }) => text.startsWith("RUN ") && text.includes(digest), + ); + + expect(verificationLayer?.text).toContain("sha256sum -c -"); + expect(verificationLayer?.text).toContain(invocation); + }); + + it("keeps wrapper prerequisites and compatibility validation in one layer", () => { + const runInstructions = dockerfileInstructions(dockerfile).filter(({ text }) => + text.startsWith("RUN "), + ); + const prerequisiteLayer = runInstructions.find(({ text }) => + text.includes("test -x /usr/bin/python3"), + ); + + expect(prerequisiteLayer?.text).toContain(`${imageProbePath} compatibility-retirement`); + }); + + it("rejects an upgrade that retains the Hermes 0.20.6 adapter", () => { + const result = runCompatibilityRetirementProbe({ version: "hermes v0.20.0" }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "installed Hermes 0.20.0 but Hermes v0.20.6 compatibility workarounds are still installed", + ); + }); + + it("accepts the reviewed Hermes version and exact one-shot completion scope", () => { + const result = runCompatibilityRetirementProbe({ version: "hermes v0.20.6" }); + + expect(result.status, result.stderr).toBe(0); + }); + + it("runs Hermes doctor before replacing its generated configuration", () => { + const run = runGeneratedConfigPreparation(); + try { + expect(run.result.status, run.result.stderr).toBe(0); + expect(fs.readFileSync(run.orderLog, "utf8")).toBe("doctor\ngenerate\n"); + expect(fs.readFileSync(path.join(run.hermesHome, "config.yaml"), "utf8")).toBe( + "model: trusted\n", + ); + expect(fs.readFileSync(path.join(run.hermesHome, ".env"), "utf8")).toBe("SAFE=1\n"); + expect(fs.statSync(path.join(run.hermesHome, "config.yaml")).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.join(run.hermesHome, ".env")).mode & 0o777).toBe(0o600); + } finally { + fs.rmSync(run.temporaryRoot, { force: true, recursive: true }); + } + }); + + it("does not generate configuration after Hermes doctor fails", () => { + const run = runGeneratedConfigPreparation(7); + try { + expect(run.result.status).toBe(1); + expect(run.result.stderr).toContain("Hermes doctor exited with status 7"); + expect(fs.readFileSync(run.orderLog, "utf8")).toBe("doctor\n"); + expect(fs.readFileSync(path.join(run.hermesHome, "config.yaml"), "utf8")).toContain( + "doctor_migrated", + ); + } finally { + fs.rmSync(run.temporaryRoot, { force: true, recursive: true }); + } + }); + + it("keeps A2A, Buzz, Google Chat, and WhatsApp Cloud disabled under hostile build inputs", () => { + const result = runNeutralPlatformProbe( + `from enum import Enum +from types import SimpleNamespace + +class Platform(Enum): + A2A = "a2a" + BUZZ = "buzz" + GOOGLE_CHAT = "google_chat" + WHATSAPP_CLOUD = "whatsapp_cloud" + +def load_gateway_config(): + disabled = lambda: SimpleNamespace(enabled=False, token=None, api_key=None, extra={}) + return SimpleNamespace(platforms={ + Platform.A2A: disabled(), + Platform.BUZZ: disabled(), + Platform.GOOGLE_CHAT: disabled(), + Platform.WHATSAPP_CLOUD: disabled(), + }) +`, + ); + + expect(result.status, result.stderr).toBe(0); + }); + + it("rejects an enabled platform from the neutral image probe", () => { + const result = runNeutralPlatformProbe( + `from enum import Enum +from types import SimpleNamespace + +class Platform(Enum): + A2A = "a2a" + BUZZ = "buzz" + GOOGLE_CHAT = "google_chat" + WHATSAPP_CLOUD = "whatsapp_cloud" + +def load_gateway_config(): + disabled = SimpleNamespace(enabled=False, token=None, api_key=None, extra={}) + enabled = SimpleNamespace(enabled=True, token="unexpected", api_key=None, extra={}) + return SimpleNamespace(platforms={ + Platform.A2A: disabled, + Platform.BUZZ: disabled, + Platform.GOOGLE_CHAT: enabled, + Platform.WHATSAPP_CLOUD: disabled, + }) +`, + ); + + expect(result.status).not.toBe(0); + }); + it("validates session state sidecars according to SQLite's selected journal mode", () => { expect(probeSource).toContain('connection.execute("PRAGMA journal_mode")'); expect(probeSource).toContain('if journal_mode == "wal":'); @@ -133,9 +444,7 @@ assert module._session_state_journal_mode(SimpleNamespace(_conn=Connection())) = expect(sessionStateLayers[0]?.start).toBe( layersFor("cron").find(({ text }) => text.includes(`${imageProbePath} cron-create`))?.start, ); - expect(sessionStateLayers[0]?.text).toContain( - "rm -f /sandbox/.hermes/runtime/state.db", - ); + expect(sessionStateLayers[0]?.text).toContain("rm -f /sandbox/.hermes/runtime/state.db"); const pluginIsolationLayer = runInstructions.find(({ text }) => text.includes("nemoclaw-hostile-user-plugin"), ); @@ -152,11 +461,27 @@ assert module._session_state_journal_mode(SimpleNamespace(_conn=Connection())) = expect(dockerfile).toContain("check_absent /sandbox/.hermes/runtime/state.db"); }); + it("does not normalize modes on removed Hermes compatibility patchers", () => { + const modeInstruction = dockerfileInstructions(dockerfile).find(({ text }) => + text.startsWith("RUN chmod 755 /usr/local/bin/nemoclaw-start "), + ); + + expect(modeInstruction?.text).toBeDefined(); + expect(modeInstruction?.text).not.toContain( + "/usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py", + ); + expect(modeInstruction?.text).not.toContain( + "/usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py", + ); + }); + it.each(commands)( "uses a checked-in probe runner instead of builder-dependent heredocs [case %#] (#7981)", (command) => { expect(dockerfile).not.toMatch(/<<-?\s*['"]?[A-Za-z_][A-Za-z0-9_]*['"]?/u); - expect(dockerfile).toContain(`COPY agents/hermes/image-build-probes.py ${imageProbePath}`); + expect(dockerfile).toMatch( + /^COPY agents\/hermes\/image-build-probes[.]py .* \/opt\/nemoclaw-hermes-config\/$/mu, + ); const normalizedDockerfile = dockerfile.replace(/\\\n/gu, "").replace(/\s+/gu, " "); expect(normalizedDockerfile).toContain(`${imageProbePath} ${command}`); diff --git a/test/agents/hermes/hermes-light-skin-boundary.test.ts b/test/agents/hermes/hermes-light-skin-boundary.test.ts index 0dc69949abd..47e6ed5721b 100644 --- a/test/agents/hermes/hermes-light-skin-boundary.test.ts +++ b/test/agents/hermes/hermes-light-skin-boundary.test.ts @@ -12,8 +12,8 @@ describe("hermes light-skin boundary check", () => { it("passes when the pinned version is reviewed", () => { expect( checkHermesLightSkinBoundary({ - dockerfileText: dockerfileWithVersion("v2026.7.20"), - reviewedVersions: ["v2026.6.19", "v2026.7.1", "v2026.7.20"], + dockerfileText: dockerfileWithVersion("v2026.8.27"), + reviewedVersions: ["v2026.6.19", "v2026.7.1", "v2026.8.27"], }), ).toBeNull(); }); @@ -21,7 +21,7 @@ describe("hermes light-skin boundary check", () => { it("fails when the pinned version has not been reviewed", () => { const error = checkHermesLightSkinBoundary({ dockerfileText: dockerfileWithVersion("v2026.8.1"), - reviewedVersions: ["v2026.6.19", "v2026.7.1", "v2026.7.20"], + reviewedVersions: ["v2026.6.19", "v2026.7.1", "v2026.8.27"], }); expect(error).toContain("needs re-review"); diff --git a/test/agents/hermes/hermes-mcp-runtime-capability.test.ts b/test/agents/hermes/hermes-mcp-runtime-capability.test.ts index 569ccecf402..a8fde0cfa55 100644 --- a/test/agents/hermes/hermes-mcp-runtime-capability.test.ts +++ b/test/agents/hermes/hermes-mcp-runtime-capability.test.ts @@ -6,10 +6,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { dockerRunCommandBetween } from "../../helpers/dockerfile-run-shell"; const ROOT = path.resolve(import.meta.dirname, "../../.."); -const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); +const IMAGE_BUILD_PROBES = path.join(ROOT, "agents", "hermes", "image-build-probes.py"); function runHermesOptionalRuntimeValidation({ mcpAvailable, @@ -22,27 +21,17 @@ function runHermesOptionalRuntimeValidation({ acpVersion?: string; acpModuleFilename?: string; }) { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-mcp-runtime-")); const toolsDir = path.join(tmp, "tools"); const acpAdapterDir = path.join(tmp, "acp_adapter"); const acpDistInfo = path.join(tmp, `agent_client_protocol-${acpVersion}.dist-info`); - const imageCommand = dockerRunCommandBetween( - dockerfile, - "# Managed MCP and ACP require their packaged Hermes client surfaces", - "# Published base images can lag Dockerfile.base", - ); - const command = imageCommand.replaceAll("/opt/hermes/.venv/bin/python -I", "python3"); try { for (const directory of [toolsDir, acpAdapterDir, acpDistInfo]) { fs.mkdirSync(directory, { recursive: true }); } fs.writeFileSync(path.join(tmp, acpModuleFilename), "# ACP SDK fixture\n"); fs.writeFileSync(path.join(acpAdapterDir, "__init__.py"), ""); - fs.writeFileSync( - path.join(acpAdapterDir, "server.py"), - "class HermesACPAgent:\n pass\n", - ); + fs.writeFileSync(path.join(acpAdapterDir, "server.py"), "class HermesACPAgent:\n pass\n"); fs.writeFileSync( path.join(acpDistInfo, "METADATA"), `Metadata-Version: 2.4\nName: agent-client-protocol\nVersion: ${acpVersion}\n`, @@ -51,17 +40,19 @@ function runHermesOptionalRuntimeValidation({ fs.writeFileSync(path.join(toolsDir, "__init__.py"), ""); fs.writeFileSync( path.join(toolsDir, "mcp_tool.py"), - `_MCP_AVAILABLE = ${mcpAvailable ? "True" : "False"}\n` + - `_MCP_HTTP_AVAILABLE = ${httpAvailable ? "True" : "False"}\n`, + "_MCP_AVAILABLE = False\n" + + "_MCP_HTTP_AVAILABLE = False\n" + + "def _ensure_mcp_sdk():\n" + + " global _MCP_AVAILABLE, _MCP_HTTP_AVAILABLE\n" + + ` _MCP_AVAILABLE = ${mcpAvailable ? "True" : "False"}\n` + + ` _MCP_HTTP_AVAILABLE = ${httpAvailable ? "True" : "False"}\n` + + " return _MCP_AVAILABLE\n", ); - return { - imageCommand, - result: spawnSync("bash", ["-c", command], { - encoding: "utf-8", - env: { ...process.env, PYTHONPATH: tmp }, - timeout: 5000, - }), - }; + return spawnSync("python3", [IMAGE_BUILD_PROBES, "managed-runtime-capability"], { + encoding: "utf-8", + env: { ...process.env, PYTHONPATH: tmp }, + timeout: 5000, + }); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -73,38 +64,29 @@ describe("Hermes managed optional runtime capability", () => { mcpAvailable: true, httpAvailable: true, }); - expect(complete.imageCommand).toContain("/opt/hermes/.venv/bin/python -I -c"); - expect(complete.imageCommand).toContain('metadata.version("agent-client-protocol")'); - expect(complete.imageCommand).toContain("import acp"); - expect(complete.imageCommand).toContain( - "from acp_adapter.server import HermesACPAgent", - ); - expect(complete.imageCommand).not.toContain("assert "); - expect(complete.result.status, complete.result.stderr).toBe(0); + expect(complete.status, complete.stderr).toBe(0); const missingHttp = runHermesOptionalRuntimeValidation({ mcpAvailable: true, httpAvailable: false, }); - expect(missingHttp.result.status).toBe(1); - expect(missingHttp.result.stderr).toContain( - "Hermes MCP Streamable HTTP runtime is unavailable", - ); + expect(missingHttp.status).toBe(1); + expect(missingHttp.stderr).toContain("Hermes MCP Streamable HTTP runtime is unavailable"); const wrongAcp = runHermesOptionalRuntimeValidation({ mcpAvailable: true, httpAvailable: true, acpVersion: "0.8.0", }); - expect(wrongAcp.result.status).toBe(1); - expect(wrongAcp.result.stderr).toContain("Hermes ACP SDK version is unavailable"); + expect(wrongAcp.status).toBe(1); + expect(wrongAcp.stderr).toContain("Hermes ACP SDK version is unavailable"); const missingAcp = runHermesOptionalRuntimeValidation({ mcpAvailable: true, httpAvailable: true, acpModuleFilename: "not_acp.py", }); - expect(missingAcp.result.status).toBe(1); - expect(missingAcp.result.stderr).toContain("No module named 'acp'"); + expect(missingAcp.status).toBe(1); + expect(missingAcp.stderr).toContain("No module named 'acp'"); }); }); diff --git a/test/agents/hermes/hermes-neutral-platform-env-activation.test.ts b/test/agents/hermes/hermes-neutral-platform-env-activation.test.ts index 24b08cc5f18..c0427722aee 100644 --- a/test/agents/hermes/hermes-neutral-platform-env-activation.test.ts +++ b/test/agents/hermes/hermes-neutral-platform-env-activation.test.ts @@ -12,6 +12,7 @@ const ROOT = path.resolve(import.meta.dirname, "../../.."); const PATCHER = path.join(ROOT, "agents", "hermes", "patch-neutral-platform-env-activation.py"); const UPSTREAM_FIXTURE = `import logging +import math import os import json from dataclasses import dataclass, field @@ -123,19 +124,4 @@ describe("Hermes neutral platform environment activation guard", () => { fs.rmSync(temporaryRoot, { force: true, recursive: true }); } }); - - it("fails closed without modifying the source when the pinned cleanup shape drifts", () => { - const drifted = UPSTREAM_FIXTURE.replace( - 'platform_config.extra.pop("_enabled_explicit", None)', - 'platform_config.extra.pop("changed_marker", None)', - ); - const { configPath, result, temporaryRoot } = runPatcher(drifted); - try { - expect(result.status).toBe(1); - expect(result.stderr).toContain("neutral platform environment source shape changed"); - expect(fs.readFileSync(configPath, "utf8")).toBe(drifted); - } finally { - fs.rmSync(temporaryRoot, { force: true, recursive: true }); - } - }); }); diff --git a/test/agents/hermes/hermes-plugin-handlers.test.ts b/test/agents/hermes/hermes-plugin-handlers.test.ts index f6ca9c04ebe..125a2988614 100644 --- a/test/agents/hermes/hermes-plugin-handlers.test.ts +++ b/test/agents/hermes/hermes-plugin-handlers.test.ts @@ -22,6 +22,81 @@ function runPython(script: string): string { } describe("Hermes NemoClaw plugin handlers", () => { + it("uses only the migrated Hermes home when no explicit home is set", () => { + const output = runPython(` +import importlib.util +import io +import json +import os +import pathlib +import sys +import types + +plugin_path = pathlib.Path(sys.argv[1]) +yaml_stub = types.ModuleType("yaml") +yaml_stub.safe_load = lambda *_args, **_kwargs: {} +sys.modules.setdefault("yaml", yaml_stub) +spec = importlib.util.spec_from_file_location("hermes_plugin", plugin_path) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +os.environ.pop("HERMES_HOME", None) +dotenv_by_path = { + "/sandbox/.hermes/.env": "NEMOCLAW_TEST_VALUE=current\\n", + "/sandbox/.hermes-data/.env": "NEMOCLAW_TEST_VALUE=legacy\\n", +} +opened = [] +def fake_open(path, *_args, **_kwargs): + opened.append(path) + return io.StringIO(dotenv_by_path[path]) +module.open = fake_open +module.os.path.exists = lambda path: path in dotenv_by_path + +loaded_homes = [] +hermes_cli = types.ModuleType("hermes_cli") +env_loader = types.ModuleType("hermes_cli.env_loader") +env_loader.load_hermes_dotenv = lambda *, hermes_home: loaded_homes.append(hermes_home) +hermes_cli.env_loader = env_loader +sys.modules["hermes_cli"] = hermes_cli +sys.modules["hermes_cli.env_loader"] = env_loader + +module._get_sandbox_info = lambda: { + "agent": "hermes", + "model": "nemotron", + "provider": "nvidia", + "base_url": "http://localhost:8642/v1", + "gateway": "running", + "port": 8642, +} +module._active_managed_gateway_services = lambda: [] +module._broker_mode_enabled = lambda: False + +value = module._get_env_value("NEMOCLAW_TEST_VALUE") +module._load_hermes_dotenv() +context = module._build_nemoclaw_agent_context() +print(json.dumps({ + "value": value, + "opened": opened, + "loaded_homes": loaded_homes, + "context": context, +})) +`); + + const result = JSON.parse(output) as { + value: string; + opened: string[]; + loaded_homes: string[]; + context: string; + }; + + expect(result.value).toBe("current"); + expect(result.opened).toContain("/sandbox/.hermes/.env"); + expect(result.opened).not.toContain("/sandbox/.hermes-data/.env"); + expect(result.loaded_homes).toEqual(["/sandbox/.hermes"]); + expect(result.context).toContain("Parent Hermes sandbox config lives under /sandbox/.hermes"); + expect(result.context).not.toContain(".hermes-data"); + }); + it("uses only allocated ASCII API ports from the supervisor or marker (#8543)", () => { const output = runPython(` import importlib.util @@ -281,6 +356,108 @@ print(json.dumps(result)) expect(result.firecrawl_url).toBe("http://host.openshell.internal:11436/firecrawl/v2/search"); }); + it("blocks private broker URLs and patches loaded Hermes URL guards", () => { + const output = runPython(` +import importlib.util +import json +import os +import pathlib +import sys +import types + +plugin_path = pathlib.Path(sys.argv[1]) +yaml_stub = types.ModuleType("yaml") +yaml_stub.safe_load = lambda *_args, **_kwargs: {} +sys.modules.setdefault("yaml", yaml_stub) +os.environ["NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER"] = "1" + +def add_module(name, module): + sys.modules[name] = module + parent, _, child = name.rpartition(".") + if parent: + parent_module = sys.modules.setdefault(parent, types.ModuleType(parent)) + setattr(parent_module, child, module) + return module + +url_safety = add_module("tools.url_safety", types.ModuleType("tools.url_safety")) +url_safety.is_safe_url = lambda _url: True +web_tools = add_module("tools.web_tools", types.ModuleType("tools.web_tools")) +web_tools.is_safe_url = lambda _url: True +browser_tool = add_module("tools.browser_tool", types.ModuleType("tools.browser_tool")) +browser_tool._is_safe_url = lambda _url: True +browser_tool._allow_private_urls_resolved = True + +spec = importlib.util.spec_from_file_location("hermes_plugin", plugin_path) +plugin = importlib.util.module_from_spec(spec) +spec.loader.exec_module(plugin) +plugin._load_hermes_config = lambda: {"security": {"allow_private_urls": False}} + +blocked_urls = [ + "http://169.254.169.254/latest/meta-data", + "http://169.254.170.2/v2/credentials", + "http://169.254.169.253/metadata", + "http://100.100.100.200/latest/meta-data", + "http://[fd00:ec2::254]/latest/meta-data", + "http://metadata.google.internal/computeMetadata/v1", + "http://metadata.goog/computeMetadata/v1", + "http://127.0.0.1", + "http://[::1]", + "http://10.0.0.1", + "http://172.16.0.1", + "http://192.168.0.1", + "http://[fc00::1]", + "http://169.254.1.1", + "http://[fe80::1]", + "http://240.0.0.1", + "http://224.0.0.1", + "http://[ff02::1]", + "http://0.0.0.0", + "http://[::]", + "http://100.64.0.1", + "https://service.internal", + "https://service.local", + "https://service.lan", + "https://single-label", + "https://123.456", + "ftp://example.com/file", + "file:///etc/passwd", + "://missing-scheme", + "https://[bad", + "", +] + +patched = plugin._install_broker_url_safety_patch() +print(json.dumps({ + "accepted": [ + plugin._broker_safe_url("https://example.com/path"), + plugin._broker_safe_url("https://8.8.8.8/dns-query"), + ], + "blocked": [plugin._broker_safe_url(url) for url in blocked_urls], + "patched": patched, + "same_predicate": [ + url_safety.is_safe_url is plugin._broker_safe_url, + web_tools.is_safe_url is plugin._broker_safe_url, + browser_tool._is_safe_url is plugin._broker_safe_url, + ], + "allow_private_urls": browser_tool._allow_private_urls_resolved, +})) +`); + + const result = JSON.parse(output) as { + accepted: boolean[]; + blocked: boolean[]; + patched: boolean; + same_predicate: boolean[]; + allow_private_urls: boolean; + }; + + expect(result.accepted).toEqual([true, true]); + expect(result.blocked.every((value) => value === false)).toBe(true); + expect(result.patched).toBe(true); + expect(result.same_predicate).toEqual([true, true, true]); + expect(result.allow_private_urls).toBe(false); + }); + it("normalizes raw messaging pseudo-tool responses before delivery", () => { const output = runPython(` import importlib.util @@ -529,6 +706,80 @@ print(json.dumps({ ); }); + it("defers the run_agent patch until the first hook after plugin registration", () => { + const output = runPython(` +import builtins +import importlib.util +import json +import pathlib +import sys +import types + +plugin_path = pathlib.Path(sys.argv[1]) +yaml_stub = types.ModuleType("yaml") +yaml_stub.safe_load = lambda *_args, **_kwargs: {} +sys.modules.setdefault("yaml", yaml_stub) + +spec = importlib.util.spec_from_file_location("hermes_plugin", plugin_path) +plugin = importlib.util.module_from_spec(spec) +spec.loader.exec_module(plugin) +plugin._install_nous_tool_broker_patch = lambda: False +plugin._install_googlechat_adapter = lambda _ctx: False + +hooks = {} +class Context: + def register_tool(self, **_kwargs): + pass + + def register_hook(self, name, callback): + hooks[name] = callback + +original_import = builtins.__import__ +def refuse_partial_run_agent(name, *args, **kwargs): + if name == "run_agent": + raise AssertionError("plugin registration imported partial run_agent") + return original_import(name, *args, **kwargs) + +builtins.__import__ = refuse_partial_run_agent +try: + plugin.register(Context()) +finally: + builtins.__import__ = original_import + +run_agent = types.ModuleType("run_agent") +class AIAgent: + @staticmethod + def _strip_think_blocks(content): + return content +run_agent.AIAgent = AIAgent +sys.modules["run_agent"] = run_agent +plugin._get_sandbox_info = lambda: { + "agent": "hermes", + "model": "n", + "provider": "p", + "base_url": "b", + "gateway": "g", + "port": 1, +} + +hooks["pre_llm_call"](user_message="hello", is_first_turn=True, platform="telegram") +normalized = AIAgent._strip_think_blocks( + 'send_message: "to telegram: registration completed"' +) +print(json.dumps({ + "hooks": sorted(hooks), + "normalized": normalized, + "patched": getattr(AIAgent, plugin._MESSAGING_RESPONSE_PATCH_ATTR, False), +})) +`); + + expect(JSON.parse(output)).toEqual({ + hooks: ["on_session_start", "pre_llm_call"], + normalized: "registration completed", + patched: true, + }); + }); + it("grounds first Telegram turns to reply directly instead of spelling tool calls", () => { const output = runPython(` import importlib.util diff --git a/test/agents/hermes/hermes-profile-policy-defaults.test.ts b/test/agents/hermes/hermes-profile-policy-defaults.test.ts index 6414fca2c57..03076610c05 100644 --- a/test/agents/hermes/hermes-profile-policy-defaults.test.ts +++ b/test/agents/hermes/hermes-profile-policy-defaults.test.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 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"; @@ -14,7 +13,6 @@ import { buildHermesManagedPolicy } from "../../../agents/hermes/config/managed- 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", baseUrl: "https://inference.local/v1", @@ -51,6 +49,17 @@ DEFAULT_CONFIG = { `; const browserFixture = `\ +import os + +_BROWSER_PASSTHROUGH_KEYS = ("npm_config_offline",) + +def _build_browser_env() -> dict: + env = {} + for _key in _BROWSER_PASSTHROUGH_KEYS: + if _key in os.environ: + env[_key] = os.environ[_key] + return env + def _restrict_browser_evaluate() -> bool: try: cfg = {} @@ -61,6 +70,9 @@ def _restrict_browser_evaluate() -> bool: `; const gatewayFixture = `\ +from dataclasses import dataclass + +@dataclass class SessionResetPolicy: mode: str = "none" # "daily", "idle", "both", or "none" @@ -85,7 +97,9 @@ def _load_show_reasoning(): # Fallback True — keep in sync with DEFAULT_CONFIG display.show_reasoning # (this loader reads the raw user YAML without the DEFAULT_CONFIG merge). return bool((_load_cfg().get("display") or {}).get("show_reasoning", True)) +`; +const tuiConfigFixture = `\ def _get_reasoning_status(cfg): return ( "show" @@ -112,16 +126,18 @@ def _resolve_pre_update_backup_mode(): return raw def _refresh(): - refresh_cua_driver = True - _update_cfg = {} - refresh_cua_driver = bool( - _update_cfg.get("refresh_cua_driver", True) - ) - return refresh_cua_driver + if True: + if True: + refresh_cua_driver = True + _update_cfg = {} + refresh_cua_driver = bool( + _update_cfg.get("refresh_cua_driver", True) + ) + return refresh_cua_driver `; function patchSource( - kind: "config" | "browser" | "gateway" | "cli" | "tui" | "agent" | "main", + kind: "config" | "browser" | "gateway" | "cli" | "tui" | "tui_config" | "agent" | "main", source: string, ) { const harness = `\ @@ -157,103 +173,149 @@ sys.stdout.write(patched) } } +function runPatchedPython(source: string, body: string, env = process.env) { + const script = `\ +import sys +namespace = {} +exec(compile(sys.stdin.read(), "", "exec"), namespace) +${body} +`; + return spawnSync("python3", ["-I", "-c", script], { + encoding: "utf8", + env, + input: source, + timeout: 5000, + }); +} + describe("Hermes profile policy defaults", () => { it("pins every config default that fresh profile homes otherwise inherit", () => { const result = patchSource("config", configFixture); expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain('"mode": "manual"'); - expect(result.stdout).toContain('"allow_unsafe_evaluate": False'); - expect(result.stdout).toContain('"restrict_evaluate": True'); - expect(result.stdout).toContain('"show_reasoning": False'); - expect(result.stdout).toContain('"show_commentary": False'); - expect(result.stdout).toContain('"pre_update_backup": False'); - expect(result.stdout).toContain('"refresh_cua_driver": False'); - expect(result.stdout.match(/NemoClaw compatibility override/gu)).toHaveLength(6); + const probe = runPatchedPython( + result.stdout, + 'import json; print(json.dumps(namespace["DEFAULT_CONFIG"], sort_keys=True))', + ); + expect(probe.status, probe.stderr).toBe(0); + expect(JSON.parse(probe.stdout)).toEqual({ + approvals: { mode: "manual" }, + browser: { allow_unsafe_evaluate: false, restrict_evaluate: true }, + display: { show_commentary: false, show_reasoning: false }, + updates: { pre_update_backup: false, refresh_cua_driver: false }, + }); }); - it("keeps the raw browser loader fail-safe when config is missing or unreadable", () => { + it("keeps the browser loader restricted and its runtime npx fallback offline", () => { const result = patchSource("browser", browserFixture); expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain('cfg_get(cfg, "browser", "restrict_evaluate"), default=True'); - expect(result.stdout).toMatch(/Could not read browser[.]restrict_evaluate[\s\S]*?return True/u); - expect(result.stdout.match(/NemoClaw compatibility override/gu)).toHaveLength(2); + const probe = runPatchedPython( + result.stdout, + ` +import json +namespace["logger"] = type("Logger", (), {"debug": lambda *args: None})() +restricted_on_error = namespace["_restrict_browser_evaluate"]() +namespace["cfg_get"] = lambda *_args: None +namespace["is_truthy_value"] = lambda value, default: default if value is None else bool(value) +restricted_when_missing = namespace["_restrict_browser_evaluate"]() +print(json.dumps({ + "offline": namespace["_build_browser_env"]()["npm_config_offline"], + "restricted_on_error": restricted_on_error, + "restricted_when_missing": restricted_when_missing, +}))`, + { ...process.env, npm_config_offline: "false" }, + ); + expect(probe.status, probe.stderr).toBe(0); + expect(JSON.parse(probe.stdout)).toEqual({ + offline: "true", + restricted_on_error: true, + restricted_when_missing: true, + }); }); it("keeps the gateway reset policy fail-safe without config.yaml", () => { const result = patchSource("gateway", gatewayFixture); expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain('mode: str = "both"'); - expect(result.stdout).toContain('mode=mode if mode is not None else "both"'); - expect(result.stdout.match(/NemoClaw compatibility override/gu)).toHaveLength(2); + const probe = runPatchedPython( + result.stdout, + 'print(namespace["SessionResetPolicy"].from_dict({}).mode)', + ); + expect(probe.status, probe.stderr).toBe(0); + expect(probe.stdout.trim()).toBe("both"); }); it("keeps the independent classic CLI display default private", () => { const result = patchSource("cli", cliFixture); expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain('"show_reasoning": False'); - expect(result.stdout).not.toContain('"show_reasoning": True'); - expect(result.stdout).toContain("NemoClaw compatibility override"); + const probe = runPatchedPython( + result.stdout, + 'print(namespace["CLI_CONFIG"]["display"]["show_reasoning"])', + ); + expect(probe.status, probe.stderr).toBe(0); + expect(probe.stdout.trim()).toBe("False"); }); - it("keeps both raw TUI reasoning fallbacks private", () => { + it("keeps the raw TUI server reasoning fallback private", () => { const result = patchSource("tui", tuiFixture); expect(result.status, result.stderr).toBe(0); - expect(result.stdout.match(/get[(]"show_reasoning", False[)]/gu)).toHaveLength(2); - expect(result.stdout).not.toContain('.get("show_reasoning", True)'); - expect(result.stdout.match(/NemoClaw compatibility override/gu)).toHaveLength(2); + const probe = runPatchedPython( + result.stdout, + 'namespace["_load_cfg"] = lambda: {}; print(namespace["_load_show_reasoning"]())', + ); + expect(probe.status, probe.stderr).toBe(0); + expect(probe.stdout.trim()).toBe("False"); + }); + + it("keeps the raw TUI config reasoning fallback private", () => { + const result = patchSource("tui_config", tuiConfigFixture); + + expect(result.status, result.stderr).toBe(0); + const probe = runPatchedPython(result.stdout, 'print(namespace["_get_reasoning_status"]({}))'); + expect(probe.status, probe.stderr).toBe(0); + expect(probe.stdout.trim()).toBe("hide"); }); it("keeps all agent commentary fallbacks private", () => { const result = patchSource("agent", agentFixture); expect(result.status, result.stderr).toBe(0); - expect(result.stdout).not.toContain("agent.show_commentary = True"); - 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(1); + const probeScript = ` +import types +import sys + +source = sys.stdin.read() +def evaluate(config): + scope = {"agent": types.SimpleNamespace(), "_agent_cfg": config} + exec(compile(source, "", "exec"), scope) + return scope["agent"].show_commentary +class BrokenConfig: + def get(self, *_args): + raise RuntimeError("broken") +print(evaluate({}), evaluate(BrokenConfig())) +`; + const probe = spawnSync("python3", ["-I", "-c", probeScript], { + encoding: "utf8", + input: result.stdout, + timeout: 5000, + }); + expect(probe.status, probe.stderr).toBe(0); + expect(probe.stdout.trim()).toBe("False False"); }); it("keeps update backup and CUA refresh fallbacks off", () => { const result = patchSource("main", mainFixture); expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain('updates_cfg.get("pre_update_backup", False)'); - 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(2); - }); - - it.each([ - [ - "missing config leaf", - "config", - configFixture.replace(' "mode": "smart",\n', ""), - "expected one unpatched occurrence, found 0", - ], - [ - "prepatched browser fallback", - "browser", - browserFixture.replace("return False", "return True"), - "expected one unpatched occurrence, found 0", - ], - [ - "duplicated gateway default", - "gateway", - `${gatewayFixture}\n${gatewayFixture}`, - "expected one unpatched occurrence, found 2", - ], - ] as const)("fails closed for %s", (_name, kind, source, error) => { - const result = patchSource(kind, source); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain(error); + const probe = runPatchedPython( + result.stdout, + 'print(namespace["_resolve_pre_update_backup_mode"](), namespace["_refresh"]())', + ); + expect(probe.status, probe.stderr).toBe(0); + expect(probe.stdout.trim()).toBe("False False"); }); it("reports an invalid managed policy as a bounded build error", () => { @@ -306,34 +368,4 @@ module._verify_session_reset_policy(reset_policy, expected) expect(result.status, result.stderr).toBe(0); }); - - it.each( - [ - "172b78ecb923048859ca177d96f5b010b44ec74bb1d13553577ff49bde1a071d", - "02b4a0a0c8fc8b204c8f818dff1dd64295a817e5543b8a643198bcedbfbbcba2", - "7221ee05798566ca7cf570035615a9b29034cf92ce5a6eaa5eec0693040c08aa", - "cbcf1780174a03b225508244575915225a36502f54ad4cddf1da644d9174fec4", - "5d00832327e4362ac75032f95003e1fa49aead4756cf7927dcfd66447b205a59", - "85b7cb13d6e6306e75d5eec46f193433df680425533b7d35ee99e0f7eab9512a", - "d6bf89a33fb708376a7ab354cff8081a3c3726dbfb91d84bbb679cd667db596c", - ], - )( - "hash-binds the reviewed source patch and probes a real config-less profile [%s]", - (expectedSourceHash) => { - const digest = createHash("sha256").update(fs.readFileSync(patcher)).digest("hex"); - - expect(dockerfile).toContain(`ARG NEMOCLAW_HERMES_PROFILE_POLICY_PATCHER_SHA256=${digest}`); - expect(dockerfile).toContain( - "COPY agents/hermes/patch-profile-policy-defaults.py " + - "/usr/local/lib/nemoclaw/patch-hermes-profile-policy-defaults.py", - ); - - expect(fs.readFileSync(patcher, "utf8")).toContain(expectedSourceHash); - - 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(dockerfile).toMatch(/image-build-probes[.]py\s+profile-policy/u); - }, - ); }); diff --git a/test/agents/hermes/hermes-secure-dir-skip-chmod-patch.test.ts b/test/agents/hermes/hermes-secure-dir-skip-chmod-patch.test.ts new file mode 100644 index 00000000000..76cbbf4760e --- /dev/null +++ b/test/agents/hermes/hermes-secure-dir-skip-chmod-patch.test.ts @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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"; + +const root = path.join(import.meta.dirname, "../../.."); +const patchPath = path.join(root, "agents", "hermes", "secure-dir-skip-chmod.patch"); +const patchSource = fs.readFileSync(patchPath, "utf8"); +const baseDockerfile = fs.readFileSync( + path.join(root, "agents", "hermes", "Dockerfile.base"), + "utf8", +); +const dockerfile = fs.readFileSync(path.join(root, "agents", "hermes", "Dockerfile"), "utf8"); + +const sourceSha256 = "ffae3271120cf53eb8a7f574f76758eac3ea172f9ddc22c1056277e37d8d392c"; +const outputSha256 = "130cf4e76d6f4f16b85517cf8d3d81dfe294aff63d6c561b979971c401f78761"; + +const upstreamFixture = `\ +import os + +def is_managed(): + return False + +def _chown_to_hermes_uid(path) -> None: + pass + + +def _secure_dir(path): + """Set directory to owner-only access (0700 by default). No-op on Windows. + + Skipped in managed mode — the NixOS module sets group-readable + permissions (0750) so interactive users in the hermes group can + share state with the gateway service. + + The mode can be overridden via the HERMES_HOME_MODE environment variable + (e.g. HERMES_HOME_MODE=0701) for deployments where a web server (nginx, + caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory. + The execute-only bit on a directory permits cd-through without exposing + directory listings. + + Also applies \`\`HERMES_UID\`\`/\`\`HERMES_GID\`\`-based ownership when those env + vars are set (#34107 — Docker deployments need this so profile subdirs + created at runtime by kanban workers don't land as root:root and block + subsequent uid-mapped workers). + """ + if is_managed(): + return + try: + mode_str = os.environ.get("HERMES_HOME_MODE", "").strip() + mode = int(mode_str, 8) if mode_str else 0o700 + except ValueError: + mode = 0o700 + try: + os.chmod(path, mode) + except (OSError, NotImplementedError): + pass + _chown_to_hermes_uid(path) +`; + +function patchedFixture(): { + readonly directory: string; + readonly config: string; +} { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-secure-dir-")); + const moduleRoot = path.join(directory, "hermes_cli"); + const config = path.join(moduleRoot, "config.py"); + fs.mkdirSync(moduleRoot); + fs.writeFileSync(config, upstreamFixture); + const applied = spawnSync("git", ["-C", directory, "apply", "--check", patchPath], { + encoding: "utf8", + }); + expect(applied.status, applied.stderr).toBe(0); + const patch = spawnSync("git", ["-C", directory, "apply", patchPath], { + encoding: "utf8", + }); + expect(patch.status, patch.stderr).toBe(0); + return { directory, config }; +} + +describe("Hermes secure-directory chmod opt-out", () => { + it("preserves NemoClaw's shared home and runtime modes when the opt-out is set", () => { + const fixture = patchedFixture(); + const home = path.join(fixture.directory, ".hermes"); + const runtime = path.join(home, "runtime"); + fs.mkdirSync(runtime, { recursive: true }); + fs.chmodSync(home, 0o3770); + fs.chmodSync(runtime, 0o2770); + const probe = `\ +import pathlib +import stat +import sys + +namespace = {} +exec(compile(pathlib.Path(sys.argv[1]).read_text(), sys.argv[1], "exec"), namespace) +for raw, expected in ((sys.argv[2], 0o3770), (sys.argv[3], 0o2770)): + path = pathlib.Path(raw) + namespace["_secure_dir"](path) + assert stat.S_IMODE(path.stat().st_mode) == expected +`; + try { + const result = spawnSync("python3", ["-I", "-c", probe, fixture.config, home, runtime], { + encoding: "utf8", + env: { ...process.env, HERMES_SKIP_CHMOD: "1" }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(fs.statSync(home).mode & 0o7777).toBe(0o3770); + expect(fs.statSync(runtime).mode & 0o7777).toBe(0o2770); + } finally { + fs.rmSync(fixture.directory, { recursive: true, force: true }); + } + }); + + it("retains upstream hardening when the opt-out is unset", () => { + const fixture = patchedFixture(); + const home = path.join(fixture.directory, ".hermes"); + fs.mkdirSync(home, { recursive: true }); + fs.chmodSync(home, 0o3770); + const probe = `\ +import pathlib +import sys + +namespace = {} +exec(compile(pathlib.Path(sys.argv[1]).read_text(), sys.argv[1], "exec"), namespace) +namespace["_secure_dir"](pathlib.Path(sys.argv[2])) +`; + try { + const env = { ...process.env }; + delete env.HERMES_HOME_MODE; + delete env.HERMES_SKIP_CHMOD; + const result = spawnSync("python3", ["-I", "-c", probe, fixture.config, home], { + encoding: "utf8", + env, + }); + + expect(result.status, result.stderr).toBe(0); + expect(fs.statSync(home).mode & 0o7777).toBe(0o700); + } finally { + fs.rmSync(fixture.directory, { recursive: true, force: true }); + } + }); + + it.each([ + ["base image", baseDockerfile], + ["final image", dockerfile], + ])("binds exact source, patch, and output identities in the %s", (_name, source) => { + const patchSha256 = createHash("sha256").update(patchSource).digest("hex"); + + expect(source).toContain(`ENV HERMES_SKIP_CHMOD=1`); + expect(source).toContain(`NEMOCLAW_HERMES_SECURE_DIR_SOURCE_SHA256=${sourceSha256}`); + expect(source).toContain(`NEMOCLAW_HERMES_SECURE_DIR_PATCH_SHA256=${patchSha256}`); + expect(source).toContain(`NEMOCLAW_HERMES_SECURE_DIR_OUTPUT_SHA256=${outputSha256}`); + }); + + it("accepts only the exact source or already-patched output in a final image", () => { + expect(dockerfile).toContain( + 'secure_dir_source_sha" = "$NEMOCLAW_HERMES_SECURE_DIR_SOURCE_SHA256"', + ); + expect(dockerfile).toContain( + 'secure_dir_source_sha" != "$NEMOCLAW_HERMES_SECURE_DIR_OUTPUT_SHA256"', + ); + }); + + it("requires exact source input and exact patched output in the base image", () => { + expect(baseDockerfile).toContain( + '"$NEMOCLAW_HERMES_SECURE_DIR_SOURCE_SHA256" /opt/hermes/hermes_cli/config.py', + ); + expect(baseDockerfile).toContain( + '"$NEMOCLAW_HERMES_SECURE_DIR_OUTPUT_SHA256" /opt/hermes/hermes_cli/config.py', + ); + }); + + it("probes the exact shared modes before cron opens its runtime ledger", () => { + const modeProbe = dockerfile.indexOf("image-build-probes.py secure-directory-modes"); + const cronProbe = dockerfile.indexOf("image-build-probes.py cron-create"); + + expect(modeProbe).toBeGreaterThanOrEqual(0); + expect(modeProbe).toBeLessThan(cronProbe); + expect(dockerfile).toContain(`stat -c '%U:%G %a' /sandbox/.hermes)" = "sandbox:sandbox 3770"`); + expect(dockerfile).toContain( + `stat -c '%U:%G %a' /sandbox/.hermes/runtime)" = "gateway:sandbox 2770"`, + ); + }); +}); diff --git a/test/agents/hermes/hermes-session-list-preview-patch.test.ts b/test/agents/hermes/hermes-session-list-preview-patch.test.ts new file mode 100644 index 00000000000..513ab9fad1f --- /dev/null +++ b/test/agents/hermes/hermes-session-list-preview-patch.test.ts @@ -0,0 +1,126 @@ +// 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, describe, expect, it } from "vitest"; + +const root = path.join(import.meta.dirname, "../../.."); +const patcher = path.join(root, "agents", "hermes", "patch-session-list-preview.py"); +const fixtures: string[] = []; +const oldQuery = "ORDER BY m.timestamp, m.id LIMIT 1"; +const oldRenderer = ` if has_titles: + title = (s.get("title") or "—")[:26] + print(f"{title:<28} {ws:<18} {last_active:<13} {s['id']}")`; + +const stateFixture = `\ +def _preview(connection, query): + return connection.execute(query).fetchone()[0] + +${Array.from( + { length: 5 }, + (_, index) => `QUERY_${index} = "SELECT content FROM messages m ${oldQuery}"`, +).join("\n")} + +def previews(connection): + return [_preview(connection, query) for query in ( + QUERY_0, QUERY_1, QUERY_2, QUERY_3, QUERY_4, + )] +`; + +const commandFixture = `\ +def render(session): + has_titles = True + s = session + ws = "workspace" + last_active = "now" + for _row in (session,): + if _row: +${oldRenderer} +`; + +function fixtureFiles() { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-session-preview-")); + fixtures.push(fixture); + const stateModule = path.join(fixture, "hermes_state.py"); + const commandModule = path.join(fixture, "sessions_cmd.py"); + fs.writeFileSync(stateModule, stateFixture); + fs.writeFileSync(commandModule, commandFixture); + return { commandModule, stateModule }; +} + +function runPatcher(stateModule: string, commandModule: string) { + return spawnSync( + "python3", + ["-I", patcher, stateModule, "--sessions-command-path", commandModule], + { + encoding: "utf8", + timeout: 5000, + }, + ); +} + +afterEach(() => { + for (const fixture of fixtures.splice(0)) { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); + +describe("Hermes session-list preview patch", () => { + it("shows the latest message while preserving explicit titles", () => { + const { commandModule, stateModule } = fixtureFiles(); + + const result = runPatcher(stateModule, commandModule); + + expect(result.status, result.stderr).toBe(0); + const probe = `\ +import contextlib +import importlib.util +import io +import json +import sqlite3 +import sys + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +state = load("patched_state", sys.argv[1]) +command = load("patched_command", sys.argv[2]) +connection = sqlite3.connect(":memory:") +connection.execute("CREATE TABLE messages (content TEXT, timestamp INTEGER, id INTEGER)") +connection.executemany( + "INSERT INTO messages VALUES (?, ?, ?)", + [("first turn", 1, 1), ("latest turn", 2, 2)], +) +output = [] +for session in ( + {"title": "seed title", "title_source": "derived", "preview": "latest turn", "id": "derived"}, + {"title": "chosen title", "title_source": "user", "preview": "latest turn", "id": "user"}, +): + stream = io.StringIO() + with contextlib.redirect_stdout(stream): + command.render(session) + output.append(stream.getvalue().strip()) +print(json.dumps({"previews": state.previews(connection), "output": output})) +`; + const probeResult = spawnSync("python3", ["-I", "-c", probe, stateModule, commandModule], { + encoding: "utf8", + timeout: 5000, + }); + expect(probeResult.status, probeResult.stderr).toBe(0); + const observed = JSON.parse(probeResult.stdout) as { + previews: string[]; + output: string[]; + }; + expect(observed.previews).toEqual(Array.from({ length: 5 }, () => "latest turn")); + expect(observed.output[0]).toContain("latest turn"); + expect(observed.output[0]).not.toContain("seed title"); + expect(observed.output[1]).toContain("chosen title"); + }); +}); diff --git a/test/agents/hermes/hermes-share-mount-deps.test.ts b/test/agents/hermes/hermes-share-mount-deps.test.ts index 0b4c12d038e..2f72e4bce1d 100644 --- a/test/agents/hermes/hermes-share-mount-deps.test.ts +++ b/test/agents/hermes/hermes-share-mount-deps.test.ts @@ -2,16 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 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 { BASE_APT_SECURITY_FUNCTIONS } from "../../helpers/base-apt-security-functions"; -import { stageFixedParser, useRealPatchedParser } from "../../helpers/python-parser-security-fixture"; +import { afterEach, describe, expect, it } from "vitest"; const ROOT = path.resolve(import.meta.dirname, "../../.."); -const HERMES_DOCKERFILE_BASE = path.join(ROOT, "agents", "hermes", "Dockerfile.base"); const HERMES_ARCHIVE_HELPER = path.join( ROOT, "scripts", @@ -19,131 +15,26 @@ const HERMES_ARCHIVE_HELPER = path.join( "download-hermes-source-archive.sh", ); const PRIVATE_CURL_DIAGNOSTIC = "private curl diagnostic must stay redacted"; +const temporaryDirectories: string[] = []; -function extractAptInstallCommand(dockerfile: string): string { - const runtimeStage = dockerfile.lastIndexOf( - "FROM node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c", - ); - expect(runtimeStage).toBeGreaterThanOrEqual(0); - const match = dockerfile - .slice(runtimeStage) - .match( - /RUN\s+apt-get update\s*&&\s*apt-get install -y --no-install-recommends[\s\S]*?&&\s*rm -rf \/var\/lib\/apt\/lists\/\*/m, - ); - expect(match).not.toBeNull(); - return match![0].replace(/^RUN\s+/, "").replace(/\\\n/g, " "); -} - -function extractHermesInstallCommand(dockerfile: string): string { - const installStart = dockerfile.indexOf("WORKDIR /opt/hermes"); - expect(installStart).toBeGreaterThanOrEqual(0); - const match = dockerfile - .slice(installStart) - .match( - /RUN\s+set -eu;[\s\S]*?ln -sf \/opt\/hermes\/\.venv\/bin\/hermes-acp \/usr\/local\/bin\/hermes-acp/m, - ); - expect(match).not.toBeNull(); - return match![0].replace(/^RUN\s+/, "").replace(/\\\n/g, " "); -} - -function extractHermesArchiveCommand(dockerfile: string): string { - const archiveStart = dockerfile.indexOf("RUN mkdir -p /opt/hermes"); - const archiveEnd = dockerfile.indexOf("\n\n# Cross-check the pinned release", archiveStart); - expect(archiveStart).toBeGreaterThanOrEqual(0); - expect(archiveEnd).toBeGreaterThan(archiveStart); - return dockerfile - .slice(archiveStart, archiveEnd) - .replace(/^RUN\s+/, "") - .replace(/\\\n/g, " "); -} - -function extractHermesIntegrityCommand(dockerfile: string): string { - const integrityStart = dockerfile.indexOf("# Cross-check the pinned release"); - const installStart = dockerfile.indexOf("WORKDIR /opt/hermes", integrityStart); - expect(integrityStart).toBeGreaterThanOrEqual(0); - expect(installStart).toBeGreaterThan(integrityStart); - const match = dockerfile.slice(integrityStart, installStart).match(/RUN\s+set -eu;[\s\S]*$/m); - expect(match).not.toBeNull(); - return match![0].replace(/^RUN\s+/, "").replace(/\\\n/g, " "); -} - -function extractHermesRuntimeGuard(dockerfile: string): string { - const guardStart = dockerfile.indexOf("RUN /usr/local/bin/hermes --version"); - const nextRun = dockerfile.indexOf("\nRUN chmod -R", guardStart); - expect(guardStart).toBeGreaterThanOrEqual(0); - expect(nextRun).toBeGreaterThan(guardStart); - return dockerfile - .slice(guardStart, nextRun) - .replace(/^RUN\s+/, "") - .replace(/\\\n/g, " "); -} - -function runLoggedShell(command: string, tmp: string, functionDefs: string[] = []) { - const logPath = path.join(tmp, "calls.log"); - const scriptPath = path.join(tmp, "run-hermes-apt-layer.sh"); - const script = [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `call_log=${JSON.stringify(logPath)}`, - "perl_base_installed=0", - "perl_installed=0", - 'apt-get() { printf "apt-get %s\\n" "$*" >> "$call_log"; [[ "$*" != *"/perl-base.deb"* ]] || perl_base_installed=1; [[ "$*" != *"/perl.deb"* ]] || perl_installed=1; }', - ...functionDefs, - command, - ].join("\n"); - fs.writeFileSync(scriptPath, script, { mode: 0o700 }); - const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 15000 }); - const calls = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : ""; - return { result, calls }; -} +type ArchiveResponse = `http:${number}` | `exit:${number}`; -function runHermesArchiveLayer( - responses: readonly string[], - expectedChecksum?: string, - archiveReplacement?: string, - input: { version?: string; output?: string } = {}, +function runArchiveDownload( + responses: readonly ArchiveResponse[], + input: { output?: string; version?: string } = {}, ) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-archive-")); - const sourceRoot = path.join(tmp, "source"); - const archiveRoot = path.join(sourceRoot, "hermes-agent-test"); - const sourceTarball = path.join(tmp, "source.tar.gz"); - const targetRoot = path.join(tmp, "target", "hermes"); - const downloadedTarball = input.output ?? path.join(tmp, "download", "hermes.tar.gz"); - const downloadedTarballPath = path.isAbsolute(downloadedTarball) - ? downloadedTarball - : path.join(tmp, downloadedTarball); - const checksumFile = `${downloadedTarball}.sha256`; - const securityPatch = path.join(tmp, "hermes-security-dependencies.patch"); - const runtimeBoundariesPatch = path.join(tmp, "hermes-runtime-boundaries.patch"); - const whatsappProxyPatch = path.join(tmp, "hermes-whatsapp-proxy.patch"); - const helperCopy = path.join(tmp, "download-hermes-source-archive.sh"); + temporaryDirectories.push(tmp); const fakeBin = path.join(tmp, "bin"); + const output = input.output ?? path.join(tmp, "hermes.tar.gz"); + const source = path.join(tmp, "source.tar.gz"); const responseFile = path.join(tmp, "responses"); const stateFile = path.join(tmp, "attempt"); const callLog = path.join(tmp, "calls.log"); const urlLog = path.join(tmp, "urls.log"); - const scriptPath = path.join(tmp, "run-hermes-archive-layer.sh"); - fs.mkdirSync(path.join(archiveRoot, "tests"), { recursive: true }); - fs.mkdirSync(path.dirname(downloadedTarballPath), { recursive: true }); fs.mkdirSync(fakeBin); - fs.writeFileSync(securityPatch, "test patch fixture\n"); - fs.writeFileSync(runtimeBoundariesPatch, "test runtime boundaries patch fixture\n"); - fs.writeFileSync(whatsappProxyPatch, "test patch fixture\n"); - fs.writeFileSync(path.join(archiveRoot, "pyproject.toml"), 'version = "test"\n'); - fs.writeFileSync( - path.join(archiveRoot, "tests", "security-fixture.txt"), - "intentionally hostile test-only URL\n", - ); - const packed = spawnSync("tar", ["-czf", sourceTarball, "-C", sourceRoot, "hermes-agent-test"], { - encoding: "utf-8", - }); - expect(packed.status, packed.stderr).toBe(0); - fs.writeFileSync(sourceTarball, archiveReplacement ?? fs.readFileSync(sourceTarball)); - const checksum = createHash("sha256").update(fs.readFileSync(sourceTarball)).digest("hex"); - const runtimeBoundariesPatchChecksum = createHash("sha256") - .update(fs.readFileSync(runtimeBoundariesPatch)) - .digest("hex"); + fs.writeFileSync(source, "verified archive payload\n"); fs.writeFileSync(responseFile, `${responses.join("\n")}\n`); fs.writeFileSync( path.join(fakeBin, "curl"), @@ -159,26 +50,22 @@ function runHermesArchiveLayer( '[ "${1:-}" = "--disable" ] || exit 67', 'output=""', 'url=""', - "fail=0", 'while [ "$#" -gt 0 ]; do', ' case "$1" in', ' -o|--output) shift; output="$1" ;;', ' --output=*) output="${1#--output=}" ;;', " --retry|--retry=*|--retry-*) exit 66 ;;", - " --fail|--fail-with-body|-*f*) fail=1 ;;", ' https://*) url="$1" ;;', " esac", " shift", "done", + '[ -n "$output" ]', '[ -n "$url" ]', 'printf "%s\\n" "$url" >> "$ARCHIVE_URL_LOG"', - 'if [ "$attempt" -gt 1 ] && [ -e "$output" ]; then', - ' printf "curl stale-output\\n" >> "$ARCHIVE_CALL_LOG"', - " exit 65", - "fi", + 'if [ "$attempt" -gt 1 ] && [ -e "$output" ]; then exit 65; fi', 'case "$response" in', ' http:200) cp "$ARCHIVE_SOURCE" "$output"; printf 200 ;;', - ` http:*) printf partial > "$output"; printf "%s" "\${response#http:}"; printf "%s\\n" ${JSON.stringify(PRIVATE_CURL_DIAGNOSTIC)} >&2; [ "$fail" -eq 0 ] || exit 22 ;;`, + ` http:*) printf partial > "$output"; printf "%s" "\${response#http:}"; printf "%s\\n" ${JSON.stringify(PRIVATE_CURL_DIAGNOSTIC)} >&2 ;;`, ` exit:*) printf partial > "$output"; printf 000; printf "%s\\n" ${JSON.stringify(PRIVATE_CURL_DIAGNOSTIC)} >&2; exit "\${response#exit:}" ;;`, " *) exit 64 ;;", "esac", @@ -195,659 +82,99 @@ function runHermesArchiveLayer( { mode: 0o700 }, ); - fs.copyFileSync(HERMES_ARCHIVE_HELPER, helperCopy); - const command = extractHermesArchiveCommand(fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8")) - .replaceAll("/tmp/hermes-security-dependencies.patch", securityPatch) - .replaceAll("/tmp/hermes-runtime-boundaries.patch", runtimeBoundariesPatch) - .replaceAll("/tmp/hermes-whatsapp-proxy.patch", whatsappProxyPatch) - .replaceAll("/tmp/hermes.tar.gz.sha256", checksumFile) - .replaceAll("/tmp/hermes.tar.gz", downloadedTarball) - .replaceAll("/opt/hermes", targetRoot) - .replaceAll("/tmp/nemoclaw-download-hermes-source-archive.sh", helperCopy); - fs.writeFileSync( - scriptPath, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `target_root=${JSON.stringify(targetRoot)}`, - `security_patch=${JSON.stringify(securityPatch)}`, - `runtime_boundaries_patch=${JSON.stringify(runtimeBoundariesPatch)}`, - `whatsapp_proxy_patch=${JSON.stringify(whatsappProxyPatch)}`, - "git() {", - ' [ "$1" = "-C" ]', - ' [ "$2" = "$target_root" ]', - ' [ "$3" = "apply" ]', - ' if [ "$4" = "--check" ]; then', - ' [ "$5" = "$security_patch" ] || [ "$5" = "$runtime_boundaries_patch" ] || [ "$5" = "$whatsapp_proxy_patch" ]', - " else", - ' [ "$4" = "$security_patch" ] || [ "$4" = "$runtime_boundaries_patch" ] || [ "$4" = "$whatsapp_proxy_patch" ]', - ' if [ "$4" = "$runtime_boundaries_patch" ]; then', - ' mkdir -p "$target_root/hermes_cli" "$target_root/tools" "$target_root/plugins/memory" "$target_root/plugins/cron_providers" "$target_root/providers"', - ' printf "%s\\n" "def nemoclaw_managed_gateway_plugins_only():" > "$target_root/hermes_constants.py"', - ' printf "%s\\n" "nemoclaw_protected_process_control" > "$target_root/hermes_cli/env_loader.py"', - ' printf "%s\\n" "Managed gateway: user and project plugins disabled" > "$target_root/hermes_cli/plugins.py"', - ' printf "%s\\n" "nemoclaw_sanitized_installer_env" > "$target_root/tools/lazy_deps.py"', - ' printf "%s\\n" "nemoclaw_managed_gateway_plugins_only" > "$target_root/plugins/memory/__init__.py"', - ' printf "%s\\n" "nemoclaw_managed_gateway_plugins_only" > "$target_root/plugins/cron_providers/__init__.py"', - ' printf "%s\\n" "nemoclaw_managed_gateway_plugins_only" > "$target_root/providers/__init__.py"', - " fi", - " fi", - "}", - `export HERMES_VERSION=${JSON.stringify(input.version ?? "v2026.7.20")}`, - `export HERMES_TARBALL_SHA256=${JSON.stringify(expectedChecksum ?? checksum)}`, - `export NEMOCLAW_HERMES_RUNTIME_BOUNDARIES_PATCH_SHA256=${JSON.stringify(runtimeBoundariesPatchChecksum)}`, - command, - ].join("\n"), - { mode: 0o700 }, - ); - const result = spawnSync("bash", [scriptPath], { + const result = spawnSync("bash", [HERMES_ARCHIVE_HELPER, input.version ?? "v2026.8.27", output], { cwd: tmp, - encoding: "utf-8", + encoding: "utf8", env: { + ...process.env, ARCHIVE_CALL_LOG: callLog, ARCHIVE_RESPONSES: responseFile, - ARCHIVE_SOURCE: sourceTarball, + ARCHIVE_SOURCE: source, ARCHIVE_STATE: stateFile, ARCHIVE_URL_LOG: urlLog, PATH: `${fakeBin}:${process.env.PATH}`, }, timeout: 15_000, }); - const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf-8").trim().split("\n") : []; - const urls = fs.existsSync(urlLog) ? fs.readFileSync(urlLog, "utf-8").trim().split("\n") : []; - return { calls, downloadedTarball: downloadedTarballPath, result, targetRoot, tmp, urls }; -} -function runHermesInstallLayer( - command: string, - tmp: string, - opts: { - uiTuiLockfile?: "directory" | "workspace" | "missing"; - webLockfile?: "directory" | "workspace" | "missing"; - whatsappBridge?: "lockfile" | "package-json"; - } = {}, -) { - const fixture = path.join(tmp, "hermes"); - const logPath = path.join(tmp, "calls.log"); - const npmCache = path.join(tmp, "root-cache", "npm"); - const electronCache = path.join(tmp, "root-cache", "electron"); - const nodeGypCache = path.join(tmp, "root-cache", "node-gyp"); - const scriptPath = path.join(tmp, "run-hermes-install-layer.sh"); - const uiTuiLockfile = opts.uiTuiLockfile ?? "missing"; - const webLockfile = opts.webLockfile ?? "directory"; - const rootLockPackages = { - ...(uiTuiLockfile === "workspace" ? { "ui-tui": {} } : {}), - ...(webLockfile === "workspace" ? { web: {} } : {}), + return { + calls: fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim().split("\n") : [], + output, + result, + source, + urls: fs.existsSync(urlLog) ? fs.readFileSync(urlLog, "utf8").trim().split("\n") : [], }; - fs.mkdirSync(path.join(fixture, "web"), { recursive: true }); - fs.writeFileSync(path.join(fixture, "pyproject.toml"), 'version = "0.16.0"\n'); - fs.writeFileSync( - path.join(fixture, "package-lock.json"), - `${JSON.stringify({ - packages: rootLockPackages, - })}\n`, - ); - fs.writeFileSync(path.join(fixture, "web", "package.json"), "{}\n"); - const writeUiTuiLockfile = { - directory: () => { - fs.mkdirSync(path.join(fixture, "ui-tui"), { recursive: true }); - fs.writeFileSync(path.join(fixture, "ui-tui", "package.json"), "{}\n"); - fs.writeFileSync(path.join(fixture, "ui-tui", "package-lock.json"), "{}\n"); - }, - missing: () => undefined, - workspace: () => { - fs.mkdirSync(path.join(fixture, "ui-tui"), { recursive: true }); - fs.writeFileSync(path.join(fixture, "ui-tui", "package.json"), "{}\n"); - }, - } satisfies Record void>; - writeUiTuiLockfile[uiTuiLockfile](); - const writeWebLockfile = { - directory: () => fs.writeFileSync(path.join(fixture, "web", "package-lock.json"), "{}\n"), - missing: () => undefined, - workspace: () => undefined, - } satisfies Record void>; - writeWebLockfile[webLockfile](); - const workspaceBuildTrees = [ - ...(uiTuiLockfile === "workspace" ? ["ui-tui"] : []), - ...(webLockfile === "workspace" ? ["web"] : []), - ]; - for (const uiDir of workspaceBuildTrees) { - const nodeModules = path.join(fixture, uiDir, "node_modules"); - fs.mkdirSync(nodeModules, { recursive: true }); - fs.writeFileSync(path.join(nodeModules, "build-only-dependency"), `${uiDir}\n`); - } - for (const cache of [npmCache, electronCache, nodeGypCache]) { - fs.mkdirSync(cache, { recursive: true }); - fs.writeFileSync(path.join(cache, "build-only-cache"), "unused after image assembly\n"); - } - if (opts.whatsappBridge) { - const bridgeDir = path.join(fixture, "scripts", "whatsapp-bridge"); - fs.mkdirSync(bridgeDir, { recursive: true }); - fs.writeFileSync(path.join(bridgeDir, "package.json"), "{}\n"); - if (opts.whatsappBridge === "lockfile") { - fs.writeFileSync(path.join(bridgeDir, "package-lock.json"), "{}\n"); - } - } - - const script = [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `cd ${JSON.stringify(fixture)}`, - `call_log=${JSON.stringify(logPath)}`, - "uv() {", - ' printf "uv %s\\n" "$*" >> "$call_log"', - ' if [ "${1:-}" = "sync" ]; then', - " mkdir -p .venv/bin", - " printf '#!/usr/bin/env sh\\nexit 0\\n' > .venv/bin/python", - " chmod 755 .venv/bin/python", - " fi", - "}", - "npm() {", - ' if [ "${1:-}" = "view" ]; then', - ' printf "%s\\n" "${HERMES_NPM_INTEGRITY}"', - " return 0", - " fi", - ' printf "npm %s\\n" "$*" >> "$call_log"', - ' if [ "${1:-}" = "ci" ]; then', - " shift", - ' prefix="."', - ' while [ "$#" -gt 0 ]; do', - ' if [ "$1" = "--prefix" ]; then', - " shift", - ' prefix="$1"', - " fi", - " shift || true", - " done", - ' [ -f "${prefix}/package-lock.json" ] || {', - ' echo "missing lockfile for ${prefix}" >&2', - " return 42", - " }", - ' mkdir -p "${prefix}/node_modules"', - " fi", - "}", - "node() {", - ' printf "node %s\\n" "$*" >> "$call_log"', - ' [ "$*" = "--experimental-test-module-mocks --test scripts/whatsapp-bridge/proxy-agent.test.mjs" ]', - "}", - 'rm() { printf "rm %s\\n" "$*" >> "$call_log"; command rm "$@"; }', - 'ln() { printf "ln %s\\n" "$*" >> "$call_log"; }', - 'export HERMES_SEMVER="0.16.0"', - 'export HERMES_NPM_INTEGRITY="sha512-test"', - 'export HERMES_UV_EXTRAS="messaging mcp"', - command - .replaceAll("/opt/hermes", fixture) - .replaceAll("/root/.npm", npmCache) - .replaceAll("/root/.cache/electron", electronCache) - .replaceAll("/root/.cache/node-gyp", nodeGypCache), - ].join("\n"); - fs.writeFileSync(scriptPath, script, { mode: 0o700 }); - const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); - const calls = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : ""; - return { cachePaths: [npmCache, electronCache, nodeGypCache], result, calls }; } -describe("Hermes share mount package parity (#2947)", () => { +afterEach(() => { + for (const tmp of temporaryDirectories.splice(0)) { + fs.rmSync(tmp, { force: true, recursive: true }); + } +}); +describe("Hermes source archive download", () => { it.each([ - { - input: { version: "2026.7.20" }, - name: "a malformed Hermes version", - failure: "invalid-version", - }, - { - input: { output: "hermes.tar.gz" }, - name: "a relative archive output", - failure: "invalid-output", - }, - ])("rejects $name before download (#9815)", ({ failure, input }) => { - const { calls, downloadedTarball, result, tmp, urls } = runHermesArchiveLayer( - ["http:200"], - undefined, - undefined, - input, - ); - try { - expect(result.status).toBe(2); - expect(calls).toEqual([]); - expect(urls).toEqual([]); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - `Hermes archive download outcome=failed-no-retry attempt=0/3 failure=${failure}\n`, - ); - expect(fs.existsSync(downloadedTarball)).toBe(false); - expect(fs.existsSync(`${downloadedTarball}.partial`)).toBe(false); - expect(fs.existsSync(`${downloadedTarball}.curl-error`)).toBe(false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("retries a throttled Hermes archive and removes upstream tests (#9815)", () => { - const { calls, result, targetRoot, tmp, urls } = runHermesArchiveLayer([ - "http:429", - "http:200", + ["invalid version", { version: "v2026.8" }, "failure=invalid-version"], + ["relative output", { output: "hermes.tar.gz" }, "failure=invalid-output"], + ])("rejects %s before network access", (_name, input, expectedError) => { + const { calls, result } = runArchiveDownload(["http:200"], input); + + expect(result.status).toBe(2); + expect(result.stderr).toContain(expectedError); + expect(calls).toEqual([]); + }); + + it("publishes a successful archive without exposing curl diagnostics", () => { + const { calls, output, result, source, urls } = runArchiveDownload(["http:200"]); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("outcome=passed-first-attempt attempt=1/3"); + expect(result.stderr).not.toContain(PRIVATE_CURL_DIAGNOSTIC); + expect(fs.readFileSync(output)).toEqual(fs.readFileSync(source)); + expect(calls).toEqual(["curl http:200"]); + expect(urls).toEqual([ + "https://github.com/NousResearch/hermes-agent/archive/refs/tags/v2026.8.27.tar.gz", ]); - try { - expect(result.status, result.stderr).toBe(0); - expect(calls).toEqual(["curl http:429", "sleep 1", "curl http:200"]); - expect(urls).toEqual([ - "https://github.com/NousResearch/hermes-agent/archive/refs/tags/v2026.7.20.tar.gz", - "https://github.com/NousResearch/hermes-agent/archive/refs/tags/v2026.7.20.tar.gz", - ]); - expect(result.stderr.split("\n").filter((line) => line.startsWith("Hermes archive"))).toEqual( - [ - "Hermes archive download outcome=transient-external attempt=1/3 failure=http-429 retry-in=1s", - "Hermes archive download outcome=passed-after-retry attempt=2/3", - ], - ); - expect(result.stderr).not.toContain(PRIVATE_CURL_DIAGNOSTIC); - expect(fs.readFileSync(path.join(targetRoot, "pyproject.toml"), "utf-8")).toContain( - 'version = "test"', - ); - expect(() => fs.lstatSync(path.join(targetRoot, "tests"))).toThrow(); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } }); - it("stops after three throttled Hermes archive attempts (#9815)", () => { - const { calls, downloadedTarball, result, tmp } = runHermesArchiveLayer([ - "http:429", - "http:429", - "http:429", - ]); - try { - expect(result.status).not.toBe(0); - expect(calls).toEqual([ - "curl http:429", - "sleep 1", - "curl http:429", - "sleep 2", - "curl http:429", - ]); - expect(result.stderr.split("\n").filter((line) => line.startsWith("Hermes archive"))).toEqual( - [ - "Hermes archive download outcome=transient-external attempt=1/3 failure=http-429 retry-in=1s", - "Hermes archive download outcome=transient-external attempt=2/3 failure=http-429 retry-in=2s", - "Hermes archive download outcome=exhausted attempt=3/3 failure=http-429", - ], - ); - expect(result.stderr).not.toContain(PRIVATE_CURL_DIAGNOSTIC); - expect(fs.existsSync(downloadedTarball)).toBe(false); - expect(fs.existsSync(`${downloadedTarball}.partial`)).toBe(false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); + it("retries only HTTP 429 and replaces partial output", () => { + const { calls, output, result } = runArchiveDownload(["http:429", "http:200"]); - it.each([ - { response: "http:404", failure: "http-404", name: "HTTP 404" }, - { response: "http:500", failure: "http-500", name: "HTTP 500" }, - { response: "exit:28", failure: "curl-exit-28", name: "transport failure" }, - ])("does not retry a terminal Hermes archive $name (#9815)", ({ failure, response }) => { - const { calls, result, tmp } = runHermesArchiveLayer([response]); - try { - expect(result.status).not.toBe(0); - expect(calls).toEqual([`curl ${response}`]); - expect(result.stderr).toBe( - `Hermes archive download outcome=failed-no-retry attempt=1/3 failure=${failure}\n`, - ); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("outcome=transient-external attempt=1/3"); + expect(result.stderr).toContain("outcome=passed-after-retry attempt=2/3"); + expect(result.stderr).not.toContain(PRIVATE_CURL_DIAGNOSTIC); + expect(calls).toEqual(["curl http:429", "sleep 1", "curl http:200"]); + expect(fs.readFileSync(output, "utf8")).toBe("verified archive payload\n"); }); - it("downloads the Hermes archive once when GitHub returns HTTP 200 (#9815)", () => { - const { calls, result, targetRoot, tmp } = runHermesArchiveLayer(["http:200"]); - try { - expect(result.status, result.stderr).toBe(0); - expect(calls).toEqual(["curl http:200"]); - expect(result.stderr).toContain( - "Hermes archive download outcome=passed-first-attempt attempt=1/3", - ); - expect(fs.readFileSync(path.join(targetRoot, "pyproject.toml"), "utf-8")).toContain( - 'version = "test"', - ); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); + it("bounds HTTP 429 retries and removes partial output", () => { + const { calls, output, result } = runArchiveDownload(["http:429", "http:429", "http:429"]); - it.each([ - { expectedChecksum: "0".repeat(64), name: "checksum mismatch" }, - { archiveReplacement: "checksum-valid malformed archive\n", name: "malformed archive" }, - ])("does not retry a Hermes archive $name (#9815)", ({ archiveReplacement, expectedChecksum }) => { - const { calls, result, targetRoot, tmp } = runHermesArchiveLayer( - ["http:200"], - expectedChecksum, - archiveReplacement, - ); - try { - expect(result.status).not.toBe(0); - expect(calls).toEqual(["curl http:200"]); - expect(result.stderr).toContain( - "Hermes archive download outcome=passed-first-attempt attempt=1/3", - ); - expect(fs.existsSync(path.join(targetRoot, "pyproject.toml"))).toBe(false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("requests gnupg, procps, e2fsprogs, and openssh-sftp-server from the Hermes base apt layer", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-share-apt-")); - const lists = path.join(tmp, "apt-lists"); - const debianSecurityDebs = path.join(tmp, "debian-security-debs"); - const nativeSecurityDebs = path.join(tmp, "native-security-debs"); - const inventoryDirectory = path.join(tmp, "security-inventory"); - const inventory = path.join(inventoryDirectory, "security-packages.txt"); - const { fixedParser, pythonShim } = stageFixedParser(tmp); - fs.mkdirSync(lists); - fs.mkdirSync(debianSecurityDebs); - fs.mkdirSync(nativeSecurityDebs); - fs.writeFileSync(path.join(nativeSecurityDebs, "libssh2-1t64.deb"), "fixed libssh2"); - fs.writeFileSync( - path.join(nativeSecurityDebs, "nemoclaw-python3.13-htmlparser-fix.deb"), - "fixed parser package", - ); - - try { - const command = extractAptInstallCommand(dockerfile) - .replaceAll("/var/lib/apt/lists", lists) - .replaceAll("/tmp/nemoclaw-debian-security", debianSecurityDebs) - .replaceAll("/tmp/nemoclaw-native-security", nativeSecurityDebs) - .replaceAll("/usr/local/share/nemoclaw/security-packages.txt", inventory) - .replaceAll("/usr/local/share/nemoclaw", inventoryDirectory) - .replaceAll("/usr/lib/python3.13/html/parser.py", fixedParser); - const { result, calls } = runLoggedShell(command, tmp, [ - 'install() { [[ "$#" -eq 8 && "$1" == "-d" && "$2" == "-o" && "$3" == "root" && "$4" == "-g" && "$5" == "root" && "$6" == "-m" && "$7" == "0755" ]] || return 64; mkdir -p "$8"; }', - 'chown() { [[ "$#" -eq 2 && "$1" == "root:root" ]] || return 64; }', - ...useRealPatchedParser(BASE_APT_SECURITY_FUNCTIONS, pythonShim), - ]); - - expect(result.status).toBe(0); - expect(calls).toContain("apt-get update"); - expect(calls).toContain("gnupg=2.4.7-21+deb13u1"); - expect(calls).toContain("procps=2:4.0.4-9"); - expect(calls).toContain("e2fsprogs=1.47.2-3+b11"); - expect(calls).toContain("openssh-sftp-server=1:10.0p1-7+deb13u4"); - expect(fs.existsSync(debianSecurityDebs)).toBe(false); - expect(fs.existsSync(nativeSecurityDebs)).toBe(false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("skips optional Hermes UI packages when old rebuild fixtures do not ship them", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-ui-layer-")); - - try { - const command = extractHermesInstallCommand(dockerfile); - const { result, calls } = runHermesInstallLayer(command, tmp); - - expect(result.status, result.stderr).toBe(0); - expect(calls).toContain("npm ci --prefer-offline --no-audit --no-fund"); - expect(calls).not.toContain("--prefix ui-tui"); - expect(calls).toContain("npm ci --prefix web --prefer-offline --no-audit --no-fund"); - expect(calls).toContain("npm run build --prefix web"); - expect(calls).toContain( - "npm ci --omit=dev --workspaces=false --prefer-offline --no-audit --no-fund", - ); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("keeps only root runtime dependencies after building workspace UIs (#7144)", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-ui-workspace-")); - const hermesRoot = path.join(tmp, "hermes"); - - try { - const command = extractHermesInstallCommand(dockerfile); - const { result, calls } = runHermesInstallLayer(command, tmp, { - uiTuiLockfile: "workspace", - webLockfile: "workspace", - }); - - expect(result.status, result.stderr).toBe(0); - expect(calls).toContain("npm ci --prefer-offline --no-audit --no-fund"); - expect(calls).not.toContain("npm ci --prefix web"); - expect(calls).toContain("npm run build --workspace web"); - const cleanInstall = "rm -rf node_modules ui-tui/node_modules web/node_modules"; - const runtimeInstall = - "npm ci --omit=dev --workspaces=false --prefer-offline --no-audit --no-fund"; - expect(calls).toContain(cleanInstall); - expect(calls).toContain(runtimeInstall); - expect(calls.indexOf(cleanInstall)).toBeLessThan(calls.indexOf(runtimeInstall)); - expect(calls).not.toContain("npm ci --omit=dev --prefer-offline --no-audit --no-fund"); - expect(calls).not.toContain("--workspace=ui-tui --include-workspace-root"); - expect(fs.existsSync(path.join(hermesRoot, "node_modules"))).toBe(true); - expect(fs.existsSync(path.join(hermesRoot, "ui-tui", "node_modules"))).toBe(false); - expect(fs.existsSync(path.join(hermesRoot, "web", "node_modules"))).toBe(false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("removes a legacy TUI build tree after bundling from its own lockfile", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-ui-legacy-")); - const uiTuiNodeModules = path.join(tmp, "hermes", "ui-tui", "node_modules"); - - try { - const command = extractHermesInstallCommand(dockerfile); - const { result, calls } = runHermesInstallLayer(command, tmp, { - uiTuiLockfile: "directory", - }); - - expect(result.status, result.stderr).toBe(0); - expect(calls).toContain("npm ci --prefix ui-tui --prefer-offline --no-audit --no-fund"); - expect(calls).toContain( - "npm ci --omit=dev --workspaces=false --prefer-offline --no-audit --no-fund", - ); - expect(fs.existsSync(uiTuiNodeModules)).toBe(false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("removes build-only caches in the Hermes dependency layer (#7144)", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-build-cache-")); - - try { - const command = extractHermesInstallCommand(dockerfile); - const { cachePaths, result } = runHermesInstallLayer(command, tmp); - - expect(result.status, result.stderr).toBe(0); - cachePaths.forEach((cachePath) => { - expect(() => fs.lstatSync(cachePath)).toThrow(); - }); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("smokes the prebuilt Hermes TUI without runtime node_modules (#7144)", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-tui-runtime-")); - const hermesRoot = path.join(tmp, "hermes"); - const fakeHermes = path.join(tmp, "hermes-cli"); - const agentBrowser = path.join(hermesRoot, "node_modules", ".bin", "agent-browser"); - const python = path.join(hermesRoot, ".venv", "bin", "python"); - const tuiEntry = path.join(hermesRoot, "ui-tui", "dist", "entry.js"); - const webIndex = path.join(hermesRoot, "hermes_cli", "web_dist", "index.html"); - const scriptPath = path.join(tmp, "run-hermes-runtime-guard.sh"); - const hermesCalls = path.join(tmp, "hermes-calls.log"); - - try { - for (const file of [agentBrowser, python, tuiEntry, webIndex]) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - } - - fs.writeFileSync( - fakeHermes, - [ - "#!/bin/sh", - `printf '%s\\n' "$*" >> ${JSON.stringify(hermesCalls)}`, - 'case "$#" in', - ' 1) [ "$1" = "--version" ] || exit 64 ;;', - ' 2) [ "$1" = "acp" ] && [ "$2" = "--check" ] || exit 64 ;;', - ' *) exit 64 ;;', - "esac", - "", - ].join("\n"), - { mode: 0o700 }, - ); - fs.writeFileSync(agentBrowser, "#!/bin/sh\nprintf 'agent-browser test\\n'\n", { - mode: 0o700, - }); - fs.writeFileSync(python, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); - fs.writeFileSync( - tuiEntry, - [ - 'const fs = require("node:fs");', - 'const path = require("node:path");', - 'const runtimeModules = path.resolve(__dirname, "../../node_modules");', - "if (fs.readdirSync(runtimeModules).length !== 0) process.exit(41);", - 'console.error("hermes-tui: no TTY");', - ].join("\n"), - ); - fs.writeFileSync(webIndex, "\n"); - - const command = extractHermesRuntimeGuard(dockerfile) - .replaceAll("/usr/local/bin/hermes", fakeHermes) - .replaceAll("/opt/hermes", hermesRoot); - fs.writeFileSync( - scriptPath, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'timeout() { shift; "$@"; }', - `export HERMES_TUI_DIR=${JSON.stringify(path.join(hermesRoot, "ui-tui"))}`, - `export HERMES_WEB_DIST=${JSON.stringify(path.join(hermesRoot, "hermes_cli", "web_dist"))}`, - command, - ].join("\n"), - { mode: 0o700 }, - ); - - const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); - - expect(result.status, result.stderr).toBe(0); - expect(result.stderr).toContain("hermes-tui: no TTY"); - expect(fs.readFileSync(hermesCalls, "utf-8").trim().split("\n")).toEqual([ - "--version", - "acp --check", - ]); - expect(fs.existsSync(agentBrowser)).toBe(true); - expect(fs.existsSync(path.join(hermesRoot, ".node_modules.runtime"))).toBe(false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("uses and removes a disposable cache for the Hermes npm integrity lookup (#7144)", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-integrity-cache-")); - const hermesRoot = path.join(tmp, "hermes"); - const integrityCache = path.join(tmp, "integrity-cache"); - const scriptPath = path.join(tmp, "run-hermes-integrity-layer.sh"); - fs.mkdirSync(hermesRoot, { recursive: true }); - fs.writeFileSync(path.join(hermesRoot, "pyproject.toml"), 'version = "0.16.0"\n'); - - try { - const command = extractHermesIntegrityCommand(dockerfile) - .replaceAll("/opt/hermes", hermesRoot) - .replaceAll("/tmp/hermes-npm-integrity-cache", integrityCache); - const script = [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `expected_cache=${JSON.stringify(integrityCache)}`, - 'npm() { [ "${npm_config_cache:-}" = "$expected_cache" ] || return 41; mkdir -p "$npm_config_cache"; printf "lookup cache\\n" > "$npm_config_cache/entry"; printf "%s\\n" "$HERMES_NPM_INTEGRITY"; }', - 'export HERMES_VERSION="v0.16.0"', - 'export HERMES_SEMVER="0.16.0"', - 'export HERMES_NPM_INTEGRITY="sha512-test"', - command, - ].join("\n"); - fs.writeFileSync(scriptPath, script, { mode: 0o700 }); - - const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); - - expect(result.status, result.stderr).toBe(0); - expect(() => fs.lstatSync(integrityCache)).toThrow(); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("pre-installs the WhatsApp bridge and runs its proxy regression test (#4764, #8087)", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wa-bridge-")); - const bridgeNodeModules = path.join( - tmp, - "hermes", - "scripts", - "whatsapp-bridge", - "node_modules", - ); - const webNodeModules = path.join(tmp, "hermes", "web", "node_modules"); - - try { - const command = extractHermesInstallCommand(dockerfile); - const { result, calls } = runHermesInstallLayer(command, tmp, { - whatsappBridge: "lockfile", - }); - - expect(result.status, result.stderr).toBe(0); - // Baking the bridge deps at build time means the runtime `hermes whatsapp` - // never needs to mkdir node_modules under read-only /opt/hermes. - expect(calls).toContain( - "npm ci --prefix scripts/whatsapp-bridge --prefer-offline --no-audit --no-fund", - ); - expect(calls).toContain( - "node --experimental-test-module-mocks --test scripts/whatsapp-bridge/proxy-agent.test.mjs", - ); - expect(fs.existsSync(bridgeNodeModules)).toBe(true); - expect(fs.existsSync(webNodeModules)).toBe(false); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("skips the WhatsApp bridge install when package.json ships without a lockfile (#4764)", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wa-bridge-nolock-")); - - try { - const command = extractHermesInstallCommand(dockerfile); - const { result, calls } = runHermesInstallLayer(command, tmp, { - whatsappBridge: "package-json", - }); - - expect(result.status, result.stderr).toBe(0); - expect(calls).not.toContain("--prefix scripts/whatsapp-bridge"); - expect(result.stdout).toContain( - "Skipping optional Hermes bridge scripts/whatsapp-bridge: package-lock.json not found", - ); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + expect(result.status).toBe(1); + expect(result.stderr).toContain("outcome=exhausted attempt=3/3 failure=http-429"); + expect(result.stderr).not.toContain(PRIVATE_CURL_DIAGNOSTIC); + expect(calls).toEqual([ + "curl http:429", + "sleep 1", + "curl http:429", + "sleep 2", + "curl http:429", + ]); + expect(fs.existsSync(output)).toBe(false); }); - it("skips the WhatsApp bridge install when the project is absent (#4764)", () => { - const dockerfile = fs.readFileSync(HERMES_DOCKERFILE_BASE, "utf-8"); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-wa-bridge-skip-")); - - try { - const command = extractHermesInstallCommand(dockerfile); - const { result, calls } = runHermesInstallLayer(command, tmp); - - expect(result.status, result.stderr).toBe(0); - expect(calls).not.toContain("--prefix scripts/whatsapp-bridge"); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + it.each([ + ["terminal HTTP response", ["http:404"] as const, "failure=http-404"], + ["server error", ["http:500"] as const, "failure=http-500"], + ["curl failure", ["exit:35"] as const, "failure=curl-exit-35"], + ])("fails closed after a %s", (_name, responses, expectedError) => { + const { calls, output, result } = runArchiveDownload(responses); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("outcome=failed-no-retry attempt=1/3"); + expect(result.stderr).toContain(expectedError); + expect(result.stderr).not.toContain(PRIVATE_CURL_DIAGNOSTIC); + expect(calls).toHaveLength(1); + expect(fs.existsSync(output)).toBe(false); }); }); diff --git a/test/agents/hermes/hermes-sqlite-temp-store-patch.test.ts b/test/agents/hermes/hermes-sqlite-temp-store-patch.test.ts index b62a2bbf874..3a924d38770 100644 --- a/test/agents/hermes/hermes-sqlite-temp-store-patch.test.ts +++ b/test/agents/hermes/hermes-sqlite-temp-store-patch.test.ts @@ -14,18 +14,12 @@ const patcher = path.join(root, "agents", "hermes", "patch-hermes-sqlite-temp-st const dockerfile = fs.readFileSync(path.join(root, "agents", "hermes", "Dockerfile"), "utf8"); const fixtures: string[] = []; -const walSetup = 'apply_wal_with_fallback(self._conn, db_label="state.db")'; +const pragmaSetup = 'apply_database_pragmas(self._conn, db_label="state.db")'; const tempStore = ' self._conn.execute("PRAGMA temp_store=MEMORY")'; const foreignKeys = ' self._conn.execute("PRAGMA foreign_keys=ON")'; -const unpatchedConnection = `${walSetup}\n${foreignKeys}`; -const legacyTempStoreConnection = `${walSetup}\n${tempStore}\n${foreignKeys}`; -const unpatchedImports = [ - "import logging", - "import random", - "import re", - "import sqlite3", - "import sys", -].join("\n"); +const unpatchedConnection = `${pragmaSetup}\n${foreignKeys}`; +const legacyTempStoreConnection = `${pragmaSetup}\n${tempStore}\n${foreignKeys}`; +const unpatchedImports = ["import os", "import re", "import sqlite3", "import sys"].join("\n"); function moduleSource(connection: string): string { return `${unpatchedImports} @@ -36,9 +30,12 @@ def get_hermes_home(): DEFAULT_DB_PATH = get_hermes_home() / "state.db" -SCHEMA_VERSION = 22 +# How long SessionDB stops attempting read-only opens after one fails. -def apply_wal_with_fallback(_connection, *, db_label): +def _connect_tracked_db(*args, **kwargs): + return sqlite3.connect(*args, **kwargs) + +def apply_database_pragmas(_connection, *, db_label): return db_label class SessionDB: @@ -47,7 +44,7 @@ class SessionDB: def connect(self): def _connect_and_init(): - self._conn = sqlite3.connect(":memory:") + self._conn = _connect_tracked_db(":memory:") ${connection} self._init_schema() _connect_and_init() @@ -74,7 +71,6 @@ afterEach(() => { fs.rmSync(fixture, { recursive: true, force: true }); } }); - describe("Hermes SQLite temp-store patch", () => { it("inserts the temp store and fixed-layout descriptor normalizer", () => { const stateModule = fixtureFile(moduleSource(unpatchedConnection)); @@ -171,7 +167,7 @@ print(f"unrelated={stat.S_IMODE(unrelated.stat().st_mode):03o}") it.each([ ["duplicate", moduleSource(`${legacyTempStoreConnection}\n${tempStore}`)], - ["partial", moduleSource(`${walSetup}\n${tempStore}`)], + ["partial", moduleSource(`${pragmaSetup}\n${tempStore}`)], ["misplaced", moduleSource(`${tempStore}\n${unpatchedConnection}`)], ])("rejects a %s connection patch", (_case, source) => { const stateModule = fixtureFile(source); diff --git a/test/agents/hermes/hermes-whatsapp-dashboard-session-patch.test.ts b/test/agents/hermes/hermes-whatsapp-dashboard-session-patch.test.ts index ebaabfd7637..91f1b2882dc 100644 --- a/test/agents/hermes/hermes-whatsapp-dashboard-session-patch.test.ts +++ b/test/agents/hermes/hermes-whatsapp-dashboard-session-patch.test.ts @@ -16,9 +16,12 @@ it("stores Hermes dashboard pairing state in the gateway session directory (#818 fs.mkdirSync(path.dirname(source), { recursive: true }); fs.writeFileSync( source, - `${"\n".repeat(8109)}def _whatsapp_session_path() -> Path:\n` + + "from pathlib import Path\nfrom typing import Any\n" + + `${"\n".repeat(9720)}def _whatsapp_session_path() -> Path:\n` + " from hermes_constants import get_hermes_dir\n\n" + - ' return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session")\n\n', + ' return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session")\n\n\n' + + "def _whatsapp_phone_from_identifier(value: Any) -> str | None:\n" + + " return None\n", ); try { @@ -27,16 +30,29 @@ it("stores Hermes dashboard pairing state in the gateway session directory (#818 encoding: "utf8", }); expect(applied.status, applied.stderr).toBe(0); - const patched = fs.readFileSync(source, "utf8"); - expect(patched).toContain('return Path("/sandbox/.hermes/platforms/whatsapp/session")'); - expect(patched).not.toContain("get_hermes_dir"); + const invoked = spawnSync( + "python3", + [ + "-I", + "-c", + [ + "import importlib.util", + "import pathlib", + "import sys", + 'spec = importlib.util.spec_from_file_location("hermes_web_server", sys.argv[1])', + "module = importlib.util.module_from_spec(spec)", + "spec.loader.exec_module(module)", + "session_path = module._whatsapp_session_path()", + "assert isinstance(session_path, pathlib.Path)", + "print(session_path)", + ].join("\n"), + source, + ], + { encoding: "utf8" }, + ); + expect(invoked.status, invoked.stderr).toBe(0); + expect(invoked.stdout.trim()).toBe("/sandbox/.hermes/platforms/whatsapp/session"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); - -it("leaves the Hermes CLI and gateway unpatched (#8184)", () => { - const patch = fs.readFileSync(PATCH, "utf8"); - expect(patch).not.toContain("diff --git a/hermes_cli/main.py"); - expect(patch).not.toContain("diff --git a/gateway/"); -}); diff --git a/test/agents/hermes/hermes-wrapper-oneshot-routing.test.ts b/test/agents/hermes/hermes-wrapper-oneshot-routing.test.ts index a6d8f8eec7f..6d67a7c2348 100644 --- a/test/agents/hermes/hermes-wrapper-oneshot-routing.test.ts +++ b/test/agents/hermes/hermes-wrapper-oneshot-routing.test.ts @@ -300,7 +300,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py one-shot routing", () path.join(dir, "hermes.real"), [ "#!/usr/bin/env bash", - 'if [ "${NEMOCLAW_HERMES_ADAPTER_VERSION_PROBE:-}" = "1" ]; then printf "Hermes Agent v0.19.0\\n"; exit 0; fi', + 'if [ "${NEMOCLAW_HERMES_ADAPTER_VERSION_PROBE:-}" = "1" ]; then printf "Hermes Agent v0.20.6\\n"; exit 0; fi', 'if [ "$1" = "-z" ]; then printf "seed:%s\\n" "$2" > "$NEMOCLAW_FAKE_SESSIONS"; exit 0; fi', 'if [ "$1" = "chat" ] && [ "$2" = "--query" ] && [ "$4" = "--quiet" ] && { [ "$5" = "--resume" ] || [ "$5" = "--continue" ]; } && [ "$6" = "seed" ]; then printf "seed:%s\\n" "$3" >> "$NEMOCLAW_FAKE_SESSIONS"; exit 0; fi', "exit 3", diff --git a/test/agents/hermes/hermes-wrapper-provider-merge.test.ts b/test/agents/hermes/hermes-wrapper-provider-merge.test.ts index 4877a491f05..90fff5be76f 100644 --- a/test/agents/hermes/hermes-wrapper-provider-merge.test.ts +++ b/test/agents/hermes/hermes-wrapper-provider-merge.test.ts @@ -345,7 +345,7 @@ describe.skipIf(!canRun)("agents/hermes/hermes-wrapper.py provider/model merge", expect(run.status).toBe(2); expect(run.realInvoked).toBe(false); - expect(run.stderr).toContain("adapter targets Hermes 0.19.0"); + expect(run.stderr).toContain("adapter targets Hermes 0.20.6"); expect(run.stderr).toContain("installed CLI reports 0.20.0"); }); diff --git a/test/automation/releases/reviewed-npm-audit-workflow.test.ts b/test/automation/releases/reviewed-npm-audit-workflow.test.ts index a08b2280fb1..d7e640aa133 100644 --- a/test/automation/releases/reviewed-npm-audit-workflow.test.ts +++ b/test/automation/releases/reviewed-npm-audit-workflow.test.ts @@ -438,7 +438,7 @@ describe("trusted reviewed npm audit workflow (#5896)", () => { id: "wechat-runtime", inputValidation: "wechat-runtime", installMode: "legacy-peer-deps", - lockSha256: "27fcc4abe6707d5d710c691b25b942a269ebe2b652c6591174ee01fe06a5df35", + lockSha256: "09a91cabd559ed2294fb263602009f9f79259e765281992e56961eed0e8c1ed9", severityThreshold: "low", signatureAudit: "retry-download-failures", }), diff --git a/test/e2e-runtime/pull-public-exact-digest.test.ts b/test/e2e-runtime/pull-public-exact-digest.test.ts index 0739842ca91..4c79b3af022 100644 --- a/test/e2e-runtime/pull-public-exact-digest.test.ts +++ b/test/e2e-runtime/pull-public-exact-digest.test.ts @@ -17,12 +17,18 @@ const firstRetryWarning = type Scenario = | "attempt-cap-exhausted" | "deadline-exhausted" + | "exhausted" | "late-success" + | "modern-transient-then-success" + | "near-match" + | "permanent-status-one" + | "reference-precedes-other-not-found" | "success" | "terminal" | "terminal-exit-one" | "terminal-layer-depth" - | "terminal-permission-denied"; + | "terminal-permission-denied" + | "transient-then-success"; function normalizeElapsed(output: string): string { return output.replace(/elapsed=[0-9]+s/gu, "elapsed="); @@ -60,6 +66,22 @@ if [ "$SCENARIO" = "terminal-layer-depth" ]; then echo "failed to register layer: max depth exceeded" >&2 exit 1 fi +if [ "$SCENARIO" = "modern-transient-then-success" ] && [ "$count" -eq 1 ]; then + echo "Error response from daemon: failed to resolve reference \"$EXPECTED_REFERENCE\": $EXPECTED_REFERENCE: not found" >&2 + exit 44 +fi +if [ "$SCENARIO" = "permanent-status-one" ]; then + echo "unexpected registry response" >&2 + exit 1 +fi +if [ "$SCENARIO" = "reference-precedes-other-not-found" ]; then + echo "$EXPECTED_REFERENCE: access denied: not found" >&2 + exit 1 +fi +if [ "$SCENARIO" = "exhausted" ] || { [ "$SCENARIO" = "transient-then-success" ] && [ "$count" -eq 1 ]; }; then + echo "ERROR: $EXPECTED_REFERENCE: not found" >&2 + exit 42 +fi if [ "$SCENARIO" = "terminal-exit-one" ]; then echo "write /var/lib/docker: no space left on device" >&2 exit 1 @@ -161,6 +183,45 @@ describe("pull-public-exact-digest", () => { expect(result.configsWereRemoved).toBe(true); }); + it("retries Docker's exact-reference transient GHCR not-found result", () => { + const result = runPuller("modern-transient-then-success"); + + expect(result.status, result.stderr).toBe(0); + expect(result.count).toBe(2); + expect(result.sleeps).toEqual(["2"]); + expect(new Set(result.configs).size).toBe(1); + expect(result.configsWereRemoved).toBe(true); + expect(normalizeElapsed(result.stderr.trim())).toBe(firstRetryWarning); + expect(normalizeElapsed(result.stdout.trim())).toContain( + "outcome=passed-after-retry attempt=2/65 elapsed= deadline=1800s", + ); + expect(result.stdout + result.stderr).not.toContain(`${reference}: not found`); + }); + + it("reports an unrecognized Docker status 1 failure without retrying", () => { + const result = runPuller("permanent-status-one"); + + expect(result.status).toBe(1); + expect(result.count).toBe(1); + expect(result.sleeps).toEqual([]); + expect(result.configsWereRemoved).toBe(true); + expect(result.stderr.trim()).toBe( + "::error::GHCR anonymous exact-digest pull outcome=failed-no-retry attempt=1/65 docker-exit=1 failure=terminal-docker-exit-1", + ); + }); + + it("does not treat a later not-found token as the exact reference missing", () => { + const result = runPuller("reference-precedes-other-not-found"); + + expect(result.status).toBe(1); + expect(result.count).toBe(1); + expect(result.sleeps).toEqual([]); + expect(result.configsWereRemoved).toBe(true); + expect(result.stderr.trim()).toBe( + "::error::GHCR anonymous exact-digest pull outcome=failed-no-retry attempt=1/65 docker-exit=1 failure=terminal-docker-exit-1", + ); + }); + it("accepts delayed anonymous visibility with bounded propagation headroom", () => { const result = runPuller("late-success"); @@ -232,29 +293,33 @@ describe("pull-public-exact-digest", () => { expect(result.stderr).toContain("failure=terminal-docker-exit-1"); }); - it("stops at the hard attempt cap even when no elapsed time passes", () => { - const result = runPuller("attempt-cap-exhausted"); + it( + "stops at the hard attempt cap even when no elapsed time passes", + () => { + const result = runPuller("attempt-cap-exhausted"); - expect(result.status).toBe(1); - expect(result.count).toBe(65); - expect(result.sleeps).toHaveLength(64); - expect(result.sleeps.slice(0, 5)).toEqual(["2", "4", "8", "16", "30"]); - expect(new Set(result.sleeps.slice(4))).toEqual(new Set(["30"])); - expect(new Set(result.configs).size).toBe(1); - expect(result.configsWereRemoved).toBe(true); - const diagnostics = result.stderr.trim().split("\n"); - expect(diagnostics).toHaveLength(65); - expect(normalizeElapsed(diagnostics[0] ?? "")).toBe(firstRetryWarning); - expect(normalizeElapsed(diagnostics[63] ?? "")).toBe( - "::warning::GHCR anonymous exact-digest pull outcome=transient-external attempt=64/65 failure=anonymous-unavailable elapsed= deadline=1800s retry-in=30s", - ); - expect(normalizeElapsed(diagnostics[64] ?? "")).toBe( - "::error::GHCR anonymous exact-digest pull outcome=exhausted attempt=65/65 failure=anonymous-unavailable limit=attempt-cap elapsed= deadline=1800s", - ); - expect(result.stderr).not.toContain("permission_denied"); - expect(result.stderr).not.toContain("manifest unknown"); - expect(result.stderr).not.toContain("anonymous HEAD request"); - }); + expect(result.status).toBe(1); + expect(result.count).toBe(65); + expect(result.sleeps).toHaveLength(64); + expect(result.sleeps.slice(0, 5)).toEqual(["2", "4", "8", "16", "30"]); + expect(new Set(result.sleeps.slice(4))).toEqual(new Set(["30"])); + expect(new Set(result.configs).size).toBe(1); + expect(result.configsWereRemoved).toBe(true); + const diagnostics = result.stderr.trim().split("\n"); + expect(diagnostics).toHaveLength(65); + expect(normalizeElapsed(diagnostics[0] ?? "")).toBe(firstRetryWarning); + expect(normalizeElapsed(diagnostics[63] ?? "")).toBe( + "::warning::GHCR anonymous exact-digest pull outcome=transient-external attempt=64/65 failure=anonymous-unavailable elapsed= deadline=1800s retry-in=30s", + ); + expect(normalizeElapsed(diagnostics[64] ?? "")).toBe( + "::error::GHCR anonymous exact-digest pull outcome=exhausted attempt=65/65 failure=anonymous-unavailable limit=attempt-cap elapsed= deadline=1800s", + ); + expect(result.stderr).not.toContain("permission_denied"); + expect(result.stderr).not.toContain("manifest unknown"); + expect(result.stderr).not.toContain("anonymous HEAD request"); + }, + 30_000, + ); it("stops at the elapsed deadline before another anonymous pull", () => { const result = runPuller("deadline-exhausted"); diff --git a/test/e2e/fixtures/fake-openai-compatible.ts b/test/e2e/fixtures/fake-openai-compatible.ts index eff31fcd5dd..5319e6e2db4 100644 --- a/test/e2e/fixtures/fake-openai-compatible.ts +++ b/test/e2e/fixtures/fake-openai-compatible.ts @@ -40,7 +40,7 @@ export interface FakeOpenAiCompatibleServerOptions { readonly apiKey?: string; readonly chatContent?: string; readonly forbiddenMarkers?: readonly string[]; - readonly launchReplyFromPrompt?: boolean; + readonly replyFromPrompt?: boolean; /** Non-secret marker expected in a request under test. */ readonly requestCanaryMarker?: string; readonly host?: string; @@ -185,7 +185,7 @@ export async function startFakeOpenAiCompatibleServer( NEMOCLAW_FAKE_OPENAI_FORBIDDEN_MARKERS: JSON.stringify(options.forbiddenMarkers ?? []), NEMOCLAW_FAKE_OPENAI_HOST: host, NEMOCLAW_FAKE_OPENAI_LOG_FILE: logFile, - NEMOCLAW_FAKE_OPENAI_LAUNCH_REPLY_FROM_PROMPT: options.launchReplyFromPrompt ? "1" : "0", + NEMOCLAW_FAKE_OPENAI_REPLY_FROM_PROMPT: options.replyFromPrompt ? "1" : "0", NEMOCLAW_FAKE_OPENAI_MAX_MODEL_LEN: options.maxModelLen !== undefined ? String(options.maxModelLen) : "", NEMOCLAW_FAKE_OPENAI_MODEL: options.model ?? "test-model", diff --git a/test/e2e/fixtures/inference-adapter.ts b/test/e2e/fixtures/inference-adapter.ts index b7479ab7d91..b8960a89dcd 100644 --- a/test/e2e/fixtures/inference-adapter.ts +++ b/test/e2e/fixtures/inference-adapter.ts @@ -394,7 +394,7 @@ export async function createE2EInferenceAdapter( // alias, so listen on the bridge-facing interfaces. The workflow uses an // ephemeral ubuntu-latest VM, an OS-assigned port, and a per-run credential. host: "0.0.0.0", - launchReplyFromPrompt: true, + replyFromPrompt: true, model, publicHost: SANDBOX_HOST_ALIAS, progress: options.progress, diff --git a/test/e2e/lib/fake-openai-compatible-api.mts b/test/e2e/lib/fake-openai-compatible-api.mts index f9b3521218d..c48c2591eea 100755 --- a/test/e2e/lib/fake-openai-compatible-api.mts +++ b/test/e2e/lib/fake-openai-compatible-api.mts @@ -28,8 +28,7 @@ const requireAuth = process.env.NEMOCLAW_FAKE_OPENAI_REQUIRE_AUTH === "1"; const requireAuthModels = process.env.NEMOCLAW_FAKE_OPENAI_REQUIRE_AUTH_MODELS === "1"; const chatContent = process.env.NEMOCLAW_FAKE_OPENAI_CHAT_CONTENT || "ok"; const responseText = process.env.NEMOCLAW_FAKE_OPENAI_RESPONSE_TEXT || chatContent; -const launchReplyFromPrompt = - process.env.NEMOCLAW_FAKE_OPENAI_LAUNCH_REPLY_FROM_PROMPT === "1"; +const replyFromPrompt = process.env.NEMOCLAW_FAKE_OPENAI_REPLY_FROM_PROMPT === "1"; const requestCanaryMarker = process.env.NEMOCLAW_FAKE_OPENAI_REQUEST_CANARY_MARKER || ""; const forbiddenMarkers = (() => { try { @@ -159,13 +158,32 @@ function latestUserPrompt(payload: JsonObject): string | null { return null; } -function requestedLaunchReply(payload: JsonObject): string | null { - if (!launchReplyFromPrompt) return null; +function requestedPromptReply(payload: JsonObject): string | null { + if (!replyFromPrompt) return null; const prompt = latestUserPrompt(payload); - const match = prompt?.match( + const launchMatch = prompt?.match( /^Join these four fragments with underscores and put only the result on its own line: NEMOCLAW, ([0-9A-F]{12}), (FIRST|SECOND), OK\. Do not use tools\.(?:\n\n[\s\S]+)?$/u, ); - return match ? `NEMOCLAW_${match[1]}_${match[2]}_OK` : null; + if (launchMatch) return `NEMOCLAW_${launchMatch[1]}_${launchMatch[2]}_OK`; + if ( + /^Remember this exact token: NEMOCLAW_5254_[0-9]+\. Reply with acknowledged\.(?:\n\n[\s\S]+)?$/u.test( + prompt ?? "", + ) + ) { + return "acknowledged"; + } + if ( + /^(?:What is seven multiplied by eight\?|Multiply seven by eight\.) Reply with only the integer\.(?:\n\n[\s\S]+)?$/u.test( + prompt ?? "", + ) + ) { + return "56"; + } + const profileMarker = prompt?.match( + /^(N8011_[0-9a-z]{8,10}_PROFILE_(?:SEED|CONTINUE))(?:\n\n[\s\S]+)?$/u, + ); + if (profileMarker) return profileMarker[1]; + return null; } const server = createServer(async (req, res) => { @@ -221,7 +239,7 @@ const server = createServer(async (req, res) => { sendJson(res, 401, { error: { message: "missing bearer credential" } }); return; } - const content = requestedLaunchReply(payload) ?? chatContent; + const content = requestedPromptReply(payload) ?? chatContent; if (payload.stream) { sendChatSse(res, content); return; diff --git a/test/e2e/live/hermes-cli-adapter-live.ts b/test/e2e/live/hermes-cli-adapter-live.ts index 64914bcf500..1db3dbb90f2 100644 --- a/test/e2e/live/hermes-cli-adapter-live.ts +++ b/test/e2e/live/hermes-cli-adapter-live.ts @@ -33,6 +33,28 @@ export function hermesSessionIds(output: string): Set { return new Set(output.match(/\b[0-9]{8}_[0-9]{6}_[a-zA-Z0-9]+\b/g) ?? []); } +export function displayedHermesSessionTitle(row: string): string { + return ( + row + .trim() + .split(/\s{2,}/u)[0] + ?.trim() ?? "" + ); +} + +export function isDisplayedHermesSessionTitleForContinuation( + row: string, + continuePrompt: string, + seedPrompt: string, +): boolean { + const displayedTitle = displayedHermesSessionTitle(row); + return ( + displayedTitle.length > 0 && + continuePrompt.startsWith(displayedTitle) && + !seedPrompt.startsWith(displayedTitle) + ); +} + export function onlyNewHermesSessionId(before: Set, after: Set): string { const created = [...after].filter((id) => !before.has(id)); expect(created).toHaveLength(1); @@ -280,7 +302,12 @@ export async function assertHermesCliAdapterLiveContract({ const profileSessionRow = stripAnsi(profileSessionsAfterContinueText) .split("\n") .find((line) => line.includes(profileSessionId)); - expect(profileSessionRow, stripAnsi(profileSessionsAfterContinueText)).toContain( - profileContinuePrompt, - ); + expect( + isDisplayedHermesSessionTitleForContinuation( + profileSessionRow ?? "", + profileContinuePrompt, + profileSeedPrompt, + ), + stripAnsi(profileSessionsAfterContinueText), + ).toBe(true); } diff --git a/test/e2e/live/mcp-bridge-servers.ts b/test/e2e/live/mcp-bridge-servers.ts index 76412029c6b..5ee743b6e55 100644 --- a/test/e2e/live/mcp-bridge-servers.ts +++ b/test/e2e/live/mcp-bridge-servers.ts @@ -542,29 +542,69 @@ export async function startCompatibleMock(options: { toolName: string, ): "target" | "miss" | "invalid" => { const parsed = parsedToolResult(index, "call_hermes_tool_search"); - if (!Array.isArray(parsed?.matches)) return "invalid"; - const matches = parsed.matches; - const hasValidEntries = matches.every( - (match) => - match && - typeof match === "object" && - !Array.isArray(match) && - typeof (match as Record).name === "string", + if (!parsed) return "invalid"; + if ( + !Array.isArray(parsed.queries) || + parsed.queries.length !== 1 || + parsed.queries[0] !== toolName || + !Number.isInteger(parsed.total_available) || + (parsed.total_available as number) < 0 || + !Array.isArray(parsed.results) || + parsed.results.length !== 1 || + !parsed.tools || + typeof parsed.tools !== "object" || + Array.isArray(parsed.tools) + ) { + return "invalid"; + } + const result = parsed.results[0]; + if (!result || typeof result !== "object" || Array.isArray(result)) return "invalid"; + const resultRecord = result as Record; + if (resultRecord.query !== toolName || !Array.isArray(resultRecord.matches)) { + return "invalid"; + } + const names = resultRecord.matches.map((match) => + typeof match === "string" ? match : undefined, ); - if (!hasValidEntries) return "invalid"; - return matches.some((match) => (match as Record).name === toolName) + if (names.some((name) => name === undefined)) return "invalid"; + const tools = parsed.tools as Record; + if ( + names.some( + (name) => + !name || + !Object.hasOwn(tools, name) || + !tools[name] || + typeof tools[name] !== "object" || + Array.isArray(tools[name]), + ) + ) { + return "invalid"; + } + return names.includes(toolName) ? "target" : "miss"; }; const hasExpectedHermesDescription = (index: number, toolName: string) => { const parsed = parsedToolResult(index, "call_hermes_tool_describe"); - const parameters = parsed?.parameters; + const tools = parsed?.tools; + const describedTool = + tools && typeof tools === "object" && !Array.isArray(tools) + ? (tools as Record)[toolName] + : undefined; + const parameters = + describedTool && typeof describedTool === "object" && !Array.isArray(describedTool) + ? (describedTool as Record).parameters + : undefined; + const description = + describedTool && typeof describedTool === "object" && !Array.isArray(describedTool) + ? (describedTool as Record).description + : undefined; const properties = parameters && typeof parameters === "object" && !Array.isArray(parameters) ? (parameters as Record).properties : undefined; return ( - parsed?.name === toolName && + typeof description === "string" && properties !== null && typeof properties === "object" && !Array.isArray(properties) && @@ -612,7 +652,7 @@ export async function startCompatibleMock(options: { plannedToolCall = { id: "call_hermes_tool_search", name: "tool_search", - arguments: { query: options.deferredToolName }, + arguments: { queries: [options.deferredToolName] }, }; } else if (toolResultCount === 1) { const searchResult = classifyHermesSearchResult(0, options.deferredToolName); @@ -620,7 +660,7 @@ export async function startCompatibleMock(options: { plannedToolCall = { id: "call_hermes_tool_describe", name: "tool_describe", - arguments: { name: options.deferredToolName }, + arguments: { names: [options.deferredToolName] }, }; } else if (searchResult === "miss") { protocolError = HERMES_DEFERRED_TOOL_SEARCH_MISS; diff --git a/test/e2e/live/rebuild-hermes.test.ts b/test/e2e/live/rebuild-hermes.test.ts index c89092e8f10..695892294e0 100644 --- a/test/e2e/live/rebuild-hermes.test.ts +++ b/test/e2e/live/rebuild-hermes.test.ts @@ -8,8 +8,10 @@ import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { loadAgent } from "../../../src/lib/agent/defs"; import { shellQuote } from "../../../src/lib/core/shell-quote"; +import type { SandboxMessagingPlan } from "../../../src/lib/messaging/manifest"; import { readManagedWorkloadAuthority } from "../../../src/lib/onboard/workload/authority.ts"; import { readSandboxBaseImageResolutionMetadata } from "../../../src/lib/sandbox-base-image"; +import { createSession, saveSession } from "../../../src/lib/state/onboard-session.ts"; import type { SandboxEntry } from "../../../src/lib/state/registry/types.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { assertCleanupSucceededOrAbsent } from "../fixtures/cleanup-resources.ts"; @@ -378,7 +380,7 @@ function seedRegistryAndSession( registry.sandboxes = registry.sandboxes ?? {}; const credentialHash = createHash("sha256").update(DISCORD_FAKE_TOKEN).digest("hex"); - const messagingPlan = { + const messagingPlan: SandboxMessagingPlan = { schemaVersion: 1, sandboxName: SANDBOX_NAME, agent: "hermes", @@ -444,7 +446,7 @@ function seedRegistryAndSession( registry.defaultSandbox = SANDBOX_NAME; writeJsonFile(REGISTRY_FILE, registry); - const session = { + const session = createSession({ sandboxName: SANDBOX_NAME, agent: "hermes" as const, status: "complete" as const, @@ -454,15 +456,15 @@ function seedRegistryAndSession( credentialEnv: "COMPATIBLE_API_KEY", preferredInferenceApi: "openai-completions", messagingPlan, - }; - writeJsonFile(SESSION_FILE, session); + }); + saveSession(session); return { - sandboxName: session.sandboxName, - agent: session.agent, - status: session.status, - provider: session.provider, - model: session.model, + sandboxName: SANDBOX_NAME, + agent: "hermes", + status: "complete", + provider: "compatible-endpoint", + model: HOSTED_MODEL, messagingPlan: { schemaVersion: messagingPlan.schemaVersion, channelIds: messagingPlan.channels.map((channel) => channel.channelId), diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index c05853161dd..7cc47e2d960 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -493,6 +493,7 @@ ], "fast": [ "test/e2e/support/gpu-e2e-helpers.test.ts", + "test/e2e/support/hermes-cli-adapter-live.test.ts", "test/e2e/support/runtime-provider-fixture.test.ts", "test/e2e/support/managed-image-cohort-contract.test.ts", "test/e2e/support/managed-image-receipt.test.ts", @@ -526,6 +527,7 @@ "test/e2e/live/hermes-e2e-phases.ts" ], "fast": [ + "test/e2e/support/hermes-cli-adapter-live.test.ts", "test/e2e/support/managed-image-cohort-contract.test.ts", "test/e2e/support/managed-image-receipt.test.ts", "test/e2e/support/stock-managed-image-workflow-boundary.test.ts", @@ -589,6 +591,7 @@ "test/e2e/live/mcp-bridge-hermes-http.ts", "test/e2e/live/mcp-bridge-onboard-env.ts", "test/e2e/live/mcp-bridge-reliability.ts", + "test/e2e/live/mcp-bridge-servers.ts", "test/e2e/live/mcp-bridge-hermes-lifecycle.ts", "test/e2e/live/mcp-bridge-phases.ts", "test/e2e/live/mcp-bridge-sandbox.ts", diff --git a/test/e2e/support/e2e-clients.test.ts b/test/e2e/support/e2e-clients.test.ts index f70ecc27ad0..9bf04d2b2e0 100644 --- a/test/e2e/support/e2e-clients.test.ts +++ b/test/e2e/support/e2e-clients.test.ts @@ -113,6 +113,17 @@ describe("E2E fixture clients", () => { ]); }); + it("does not require a stock-image receipt for onboarding help", async () => { + const runner = new FakeRunner(); + const host = new HostCliClient(runner); + + await expect( + host.command("node", ["/workspace/bin/nemoclaw.js", "onboard", "--help"], { + env: { E2E_MANAGED_IMAGE_REVISION: "a".repeat(40) }, + }), + ).resolves.toMatchObject({ exitCode: 0 }); + }); + it.each([ { exitCode: 0, expected: true, label: "available" }, { exitCode: 1, expected: false, label: "missing" }, diff --git a/test/e2e/support/hermes-cli-adapter-live.test.ts b/test/e2e/support/hermes-cli-adapter-live.test.ts new file mode 100644 index 00000000000..cdf046d8ac1 --- /dev/null +++ b/test/e2e/support/hermes-cli-adapter-live.test.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + displayedHermesSessionTitle, + isDisplayedHermesSessionTitleForContinuation, +} from "../live/hermes-cli-adapter-live.ts"; + +describe("Hermes CLI adapter live assertions", () => { + it.each([ + [ + "N8011_mthe9zxn_PROFILE_CONTINUE sandbox just now 20260831_153001_12c622", + "N8011_mthe9zxn_PROFILE_CONTINUE", + ], + [ + "N8011_mthe9zxn_PROFILE_CON sandbox just now 20260831_153001_12c622", + "N8011_mthe9zxn_PROFILE_CON", + ], + ])("extracts the displayed continued-session title from %j", (row, expected) => { + expect(displayedHermesSessionTitle(row)).toBe(expected); + }); + + it.each([ + ["N8011_mthe9zxn_PROFILE_CONTINUE sandbox just now 20260831_153001_12c622", true], + ["N8011_mthe9zxn_PROFILE_CON sandbox just now 20260831_153001_12c622", true], + ["N8011_mthe9zxn_PROFILE_ sandbox just now 20260831_153001_12c622", false], + ["N8011_mthe9zxn_PROFILE_SEED sandbox just now 20260831_153001_12c622", false], + ["", false], + ])("classifies displayed continuation row %j as %s", (row, expected) => { + expect( + isDisplayedHermesSessionTitleForContinuation( + row, + "N8011_mthe9zxn_PROFILE_CONTINUE", + "N8011_mthe9zxn_PROFILE_SEED", + ), + ).toBe(expected); + }); +}); diff --git a/test/e2e/support/hermes-langfuse-credential-patch.test.ts b/test/e2e/support/hermes-langfuse-credential-patch.test.ts index 9694ec96129..00609cf1427 100644 --- a/test/e2e/support/hermes-langfuse-credential-patch.test.ts +++ b/test/e2e/support/hermes-langfuse-credential-patch.test.ts @@ -18,11 +18,13 @@ const patcherPath = fileURLToPath( const pinnedValidatorFixture = `\ import os import re +import threading from typing import Any, Dict, Optional Langfuse = Any _LANGFUSE_CLIENT = None _INIT_FAILED = object() +_LANGFUSE_CLIENT_LOCK = threading.Lock() class _Logger: def warning(self, *_args: Any) -> None: @@ -55,9 +57,10 @@ def _validate_langfuse_key(env_name: str, value: str) -> Optional[str]: def _get_langfuse() -> Optional[Langfuse]: global _LANGFUSE_CLIENT - base_url = _env("HERMES_LANGFUSE_BASE_URL") or _env("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com" - environment = _env("HERMES_LANGFUSE_ENV") or _env("LANGFUSE_ENV") - return None + with _LANGFUSE_CLIENT_LOCK: + base_url = _env("HERMES_LANGFUSE_BASE_URL") or _env("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com" + environment = _env("HERMES_LANGFUSE_ENV") or _env("LANGFUSE_ENV") + return None `; const validatorAssertions = `\ diff --git a/test/e2e/support/inference-adapter.test.ts b/test/e2e/support/inference-adapter.test.ts index aa7ffdf6286..006aeb39cdd 100644 --- a/test/e2e/support/inference-adapter.test.ts +++ b/test/e2e/support/inference-adapter.test.ts @@ -205,6 +205,31 @@ describe("E2E inference adapter", () => { }); }); + const hermesSessionMarker = ["NEMOCLAW", "5254", "123456"].join("_"); + + it.each([ + [`Remember this exact token: ${hermesSessionMarker}. Reply with acknowledged.`, "acknowledged"], + ["What is seven multiplied by eight? Reply with only the integer.", "56"], + ["Multiply seven by eight. Reply with only the integer.", "56"], + ["N8011_m1h2j3k4_PROFILE_SEED", "N8011_m1h2j3k4_PROFILE_SEED"], + ["N8011_m1h2j3k4_PROFILE_CONTINUE", "N8011_m1h2j3k4_PROFILE_CONTINUE"], + ] as const)("returns the deterministic Hermes session reply for %s", async (prompt, expected) => { + const adapter = await createAdapter({ env: {} }); + expect( + await adapter.directChat(`${prompt}\n\nfixture`), + ).toMatchObject({ choices: [{ message: { content: expected } }] }); + }); + + it("keeps the PONG fallback for an unrelated mock prompt", async () => { + const adapter = await createAdapter({ env: {} }); + expect(await adapter.directChat("Explain seven multiplied by eight.")).toMatchObject({ + choices: [{ message: { content: "PONG" } }], + }); + expect(await adapter.directChat("N8011_NOT_HEX_PROFILE_SEED")).toMatchObject({ + choices: [{ message: { content: "PONG" } }], + }); + }); + it("keeps unrelated ambient secrets out of adapter and fake-server child environments", async () => { const secretName = "UNRELATED_E2E_SENTINEL_SECRET"; const secretValue = "sentinel-value-that-must-not-propagate"; diff --git a/test/e2e/support/managed-image-receipt.test.ts b/test/e2e/support/managed-image-receipt.test.ts index e9e4e1413f6..4fbf778aeea 100644 --- a/test/e2e/support/managed-image-receipt.test.ts +++ b/test/e2e/support/managed-image-receipt.test.ts @@ -454,6 +454,13 @@ describe("stock E2E managed-image receipt assertion", () => { { E2E_MANAGED_IMAGE_REVISION: REVISION }, ), ).toBe(false); + expect( + shouldAssertStockManagedImageReceipt( + "node", + ["/workspace/bin/nemoclaw.js", "onboard", "--help"], + { E2E_MANAGED_IMAGE_REVISION: REVISION }, + ), + ).toBe(false); expect( shouldAssertStockManagedImageReceipt("/workspace/bin/nemoclaw.js", ["onboard"], { E2E_MANAGED_IMAGE_REVISION: REVISION, diff --git a/test/e2e/support/mcp-bridge-tool-discovery.test.ts b/test/e2e/support/mcp-bridge-tool-discovery.test.ts index 85841251b8a..93585cbe331 100644 --- a/test/e2e/support/mcp-bridge-tool-discovery.test.ts +++ b/test/e2e/support/mcp-bridge-tool-discovery.test.ts @@ -782,19 +782,28 @@ describe("Hermes deferred MCP tool discovery", () => { const firstSearch = expectToolCall( await requestCompatibleMessage(compatibleMock, messages), "tool_search", - { query: DEFERRED_TOOL_NAME }, + { queries: [DEFERRED_TOOL_NAME] }, ); expect(firstSearch.id).toBe("call_hermes_tool_search"); - recordToolResult(messages, firstSearch, { matches: [{ name: DEFERRED_TOOL_NAME }] }); + recordToolResult(messages, firstSearch, { + queries: [DEFERRED_TOOL_NAME], + total_available: 1, + results: [{ query: DEFERRED_TOOL_NAME, matches: [DEFERRED_TOOL_NAME] }], + tools: { [DEFERRED_TOOL_NAME]: { description: "Deferred echo" } }, + }); const description = expectToolCall( await requestCompatibleMessage(compatibleMock, messages), "tool_describe", - { name: DEFERRED_TOOL_NAME }, + { names: [DEFERRED_TOOL_NAME] }, ); recordToolResult(messages, description, { - name: DEFERRED_TOOL_NAME, - parameters: { properties: { challenge: { type: "string" } } }, + tools: { + [DEFERRED_TOOL_NAME]: { + description: "Deferred echo", + parameters: { properties: { challenge: { type: "string" } } }, + }, + }, }); const deferredCall = expectToolCall( @@ -812,6 +821,27 @@ describe("Hermes deferred MCP tool discovery", () => { expect(finalMessage.tool_calls).toBeUndefined(); }); + it("accepts the Hermes 0.20.6 multi-query tool_search result", async () => { + compatibleMock = await startDeferredCompatibleMock(); + const messages: CompatibleMessage[] = [{ role: "user", content: "call deferred tool" }]; + + const firstSearch = expectToolCall( + await requestCompatibleMessage(compatibleMock, messages), + "tool_search", + { queries: [DEFERRED_TOOL_NAME] }, + ); + recordToolResult(messages, firstSearch, { + queries: [DEFERRED_TOOL_NAME], + total_available: 1, + results: [{ query: DEFERRED_TOOL_NAME, matches: [DEFERRED_TOOL_NAME] }], + tools: { [DEFERRED_TOOL_NAME]: { description: "Deferred echo" } }, + }); + + expectToolCall(await requestCompatibleMessage(compatibleMock, messages), "tool_describe", { + names: [DEFERRED_TOOL_NAME], + }); + }); + it("stops after one well-formed tool_search miss", async () => { compatibleMock = await startDeferredCompatibleMock(); const messages: CompatibleMessage[] = [{ role: "user", content: "call deferred tool" }]; @@ -819,10 +849,15 @@ describe("Hermes deferred MCP tool discovery", () => { const firstSearch = expectToolCall( await requestCompatibleMessage(compatibleMock, messages), "tool_search", - { query: DEFERRED_TOOL_NAME }, + { queries: [DEFERRED_TOOL_NAME] }, ); expect(firstSearch.id).toBe("call_hermes_tool_search"); - recordToolResult(messages, firstSearch, { matches: [] }); + recordToolResult(messages, firstSearch, { + queries: [DEFERRED_TOOL_NAME], + total_available: 1, + results: [{ query: DEFERRED_TOOL_NAME, matches: [] }], + tools: {}, + }); const terminalMessage = await requestCompatibleMessage(compatibleMock, messages); expect(terminalMessage).toMatchObject({ @@ -839,10 +874,102 @@ describe("Hermes deferred MCP tool discovery", () => { const firstSearch = expectToolCall( await requestCompatibleMessage(compatibleMock, messages), "tool_search", - { query: DEFERRED_TOOL_NAME }, + { queries: [DEFERRED_TOOL_NAME] }, ); expect(firstSearch.id).toBe("call_hermes_tool_search"); - recordToolResult(messages, firstSearch, { matches: [{ unexpected: true }] }); + recordToolResult(messages, firstSearch, { + queries: [DEFERRED_TOOL_NAME], + total_available: 1, + results: [{ query: DEFERRED_TOOL_NAME, matches: [{ unexpected: true }] }], + tools: {}, + }); + + const terminalMessage = await requestCompatibleMessage(compatibleMock, messages); + expect(terminalMessage).toMatchObject({ + role: "assistant", + content: "mock protocol error: Hermes returned an unexpected deferred tool result sequence", + }); + expect(terminalMessage.tool_calls).toBeUndefined(); + }); + + it("rejects ambiguous legacy and multi-query tool_search results", async () => { + compatibleMock = await startDeferredCompatibleMock(); + const messages: CompatibleMessage[] = [{ role: "user", content: "call deferred tool" }]; + + const firstSearch = expectToolCall( + await requestCompatibleMessage(compatibleMock, messages), + "tool_search", + { queries: [DEFERRED_TOOL_NAME] }, + ); + recordToolResult(messages, firstSearch, { + matches: [{ name: DEFERRED_TOOL_NAME }], + results: [{ query: DEFERRED_TOOL_NAME, matches: [DEFERRED_TOOL_NAME] }], + }); + + const terminalMessage = await requestCompatibleMessage(compatibleMock, messages); + expect(terminalMessage).toMatchObject({ + role: "assistant", + content: "mock protocol error: Hermes returned an unexpected deferred tool result sequence", + }); + expect(terminalMessage.tool_calls).toBeUndefined(); + }); + + it("rejects a legacy-only Hermes tool_search result", async () => { + compatibleMock = await startDeferredCompatibleMock(); + const messages: CompatibleMessage[] = [{ role: "user", content: "call deferred tool" }]; + + const firstSearch = expectToolCall( + await requestCompatibleMessage(compatibleMock, messages), + "tool_search", + { queries: [DEFERRED_TOOL_NAME] }, + ); + recordToolResult(messages, firstSearch, { + matches: [{ name: DEFERRED_TOOL_NAME }], + }); + + const terminalMessage = await requestCompatibleMessage(compatibleMock, messages); + expect(terminalMessage).toMatchObject({ + role: "assistant", + content: "mock protocol error: Hermes returned an unexpected deferred tool result sequence", + }); + expect(terminalMessage.tool_calls).toBeUndefined(); + }); + + it.each([ + [ + "missing total", + { + queries: [DEFERRED_TOOL_NAME], + results: [{ query: DEFERRED_TOOL_NAME, matches: [] }], + tools: {}, + }, + ], + [ + "missing tools", + { + queries: [DEFERRED_TOOL_NAME], + total_available: 1, + results: [{ query: DEFERRED_TOOL_NAME, matches: [] }], + }, + ], + [ + "wrong query echo", + { + queries: ["different query"], + total_available: 1, + results: [{ query: DEFERRED_TOOL_NAME, matches: [] }], + tools: {}, + }, + ], + ])("rejects a Hermes tool_search result with %s", async (_condition, result) => { + compatibleMock = await startDeferredCompatibleMock(); + const messages: CompatibleMessage[] = [{ role: "user", content: "call deferred tool" }]; + const firstSearch = expectToolCall( + await requestCompatibleMessage(compatibleMock, messages), + "tool_search", + { queries: [DEFERRED_TOOL_NAME] }, + ); + recordToolResult(messages, firstSearch, result); const terminalMessage = await requestCompatibleMessage(compatibleMock, messages); expect(terminalMessage).toMatchObject({ diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index ff7f93ac181..aacee435803 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -11,9 +11,9 @@ import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; import { applyFixtureProviderPolicyEndpoint } from "../fixtures/gateway-providers.ts"; +import { buildProcessTokenProbe } from "../fixtures/process-token-probe.ts"; import { buildSandboxNodeInvocation, buildSandboxShellInvocation, diff --git a/test/helpers/hermes-wrapper-harness.ts b/test/helpers/hermes-wrapper-harness.ts index 7269102ee0c..8c5d3300b2a 100644 --- a/test/helpers/hermes-wrapper-harness.ts +++ b/test/helpers/hermes-wrapper-harness.ts @@ -112,7 +112,7 @@ export function runWrapper( const stubExit = opts.stub?.exitCode ?? 0; const stubScript = [ "#!/usr/bin/env bash", - `if [ "\${NEMOCLAW_HERMES_ADAPTER_VERSION_PROBE:-}" = "1" ]; then printf 'Hermes Agent v${opts.upstreamVersion ?? "0.19.0"}\\n'; exit 0; fi`, + `if [ "\${NEMOCLAW_HERMES_ADAPTER_VERSION_PROBE:-}" = "1" ]; then printf 'Hermes Agent v${opts.upstreamVersion ?? "0.20.6"}\\n'; exit 0; fi`, `node -e 'const fs=require("node:fs"); fs.writeFileSync(process.argv[1], JSON.stringify(process.argv.slice(3))); fs.writeFileSync(process.argv[2], JSON.stringify(process.env))' ${JSON.stringify(marker)} ${JSON.stringify(envMarker)} "$@"`, stubStdout ? `cat <<'__NEMOCLAW_STUB_EOF__'\n${stubStdout}\n__NEMOCLAW_STUB_EOF__` : "", stubStderr diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index c4e2936ac3e..e58aa89686f 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -571,7 +571,18 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): detected: false, sessions: [], }); - vi.spyOn(sandboxVersion, "checkAgentVersion").mockImplementation(() => { + vi.spyOn(sandboxVersion, "checkAgentVersion").mockImplementation((...args: unknown[]) => { + const options = args[1] as { forceProbe?: boolean } | undefined; + if (options?.forceProbe) { + const expectedVersion = overrides.versionCheck?.expectedVersion ?? "0.2.0"; + return { + expectedVersion, + sandboxVersion: expectedVersion, + isStale: false, + verificationFailed: false, + detectionMethod: "ssh-exec", + }; + } Object.assign(currentSandboxEntry, overrides.entryUpdatesAfterVersionCheck ?? {}); return ( overrides.versionCheck ?? { diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index 6689594782a..ea73d442abc 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -100,10 +100,6 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ "test/inference/managed/managed-inference-catalog-compiler.test.ts", ), }, - { - pattern: /(?:^|\/)internal\/security-reviews\/hermes-0\.19\.0-dependency-review\.md$/, - testsToRun: runTests("test/agents/hermes/hermes-dependency-review.test.ts"), - }, { pattern: /(?:^|\/)\.github\/actions\/resolve-hermes-base-image\/action\.yaml$/, testsToRun: runTests("test/platform/images/base-image-resolver-helper.test.ts"), @@ -119,17 +115,29 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ { pattern: /(?:^|\/)agents\/hermes\/Dockerfile\.base$/, testsToRun: runTests( - "test/agents/hermes/hermes-dependency-review.test.ts", "test/agents/hermes/hermes-share-mount-deps.test.ts", "test/inference/managed/managed-image-publication-workflow.test.ts", "test/runtime/sandbox/sandbox-provisioning.test.ts", ), }, + { + pattern: /(?:^|\/)agents\/hermes\/image-build-probes\.py$/, + testsToRun: runTests("test/agents/hermes/hermes-image-build-probes.test.ts"), + }, + { + pattern: /(?:^|\/)agents\/hermes\/patch-cron-restore-drain\.py$/, + testsToRun: runTests("test/agents/hermes/hermes-cron-restore-drain-patch.test.ts"), + }, + { + pattern: /(?:^|\/)agents\/hermes\/patch-session-list-preview\.py$/, + testsToRun: runTests("test/agents/hermes/hermes-session-list-preview-patch.test.ts"), + }, { pattern: /(?:^|\/)(agents\/(?:hermes|langchain-deepagents-code)\/)?Dockerfile$/, testsToRun: (_file, match) => { if (match[1] === "agents/hermes/") { return [ + "src/lib/onboard/experimental/hermes-portable-build-context.test.ts", "src/lib/onboard/managed-startup-profile.test.ts", "test/agents/hermes/hermes-mcp-runtime-capability.test.ts", ]; @@ -163,6 +171,10 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ pattern: /(?:^|\/)nemoclaw-blueprint\/policies\/presets\/local-memory\.yaml$/, testsToRun: runTests("test/onboarding/effective-policy-contracts.test.ts"), }, + { + pattern: /(?:^|\/)nemoclaw-blueprint\/policies\/presets\/nous-browser\.yaml$/, + testsToRun: runTests("test/onboarding/effective-policy-contracts.test.ts"), + }, { pattern: /(?:^|\/)nemoclaw-blueprint\/policies\/presets\/claude-code\.yaml$/, testsToRun: runTests("test/onboarding/effective-policy-contracts.test.ts"), diff --git a/test/inference/managed/managed-gateway-control-deadline.test.ts b/test/inference/managed/managed-gateway-control-deadline.test.ts index 420b934d95d..cd4104d6cca 100644 --- a/test/inference/managed/managed-gateway-control-deadline.test.ts +++ b/test/inference/managed/managed-gateway-control-deadline.test.ts @@ -177,7 +177,7 @@ def expire_runtime_validation(_script, _environment): preflight_clock[0] = 1.0 -control._validate_runtime_environment = expire_runtime_validation +control._validate_managed_gateway_environment = expire_runtime_validation control._verify_locked_hermes_hash = lambda: hash_checks.append("called") preflight_after_validation = error_code( lambda: control._hermes_preflight(EnvironmentReader(), supervisor, 1.0) diff --git a/test/inference/managed/managed-gateway-control.test.ts b/test/inference/managed/managed-gateway-control.test.ts index 8fc7609f387..d3a68fa2daa 100644 --- a/test/inference/managed/managed-gateway-control.test.ts +++ b/test/inference/managed/managed-gateway-control.test.ts @@ -194,7 +194,10 @@ with tempfile.TemporaryDirectory() as root: "usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py", ) with open(boundary_path, "w", encoding="utf-8") as stream: - stream.write("# trusted validator fixture\n") + stream.write( + "def validate_managed_gateway_env(environment):\n" + " return int('HERMES_ENABLE_PROJECT_PLUGINS' in environment)\n" + ) os.chmod(boundary_path, 0o755) with control.ProcReader(proc_root) as reader: @@ -416,13 +419,13 @@ with tempfile.TemporaryDirectory() as root: preflight_steps = [] real_validator = control._run_fixed_validator - real_runtime_validator = control._validate_runtime_environment + real_runtime_validator = control._validate_managed_gateway_environment real_hash_check = control._verify_locked_hermes_hash control._run_fixed_validator = lambda script, arguments, _recovery_deadline=None: preflight_steps.append({ "script": script, "arguments": arguments, }) - control._validate_runtime_environment = lambda script, environment: preflight_steps.append({ + control._validate_managed_gateway_environment = lambda script, environment: preflight_steps.append({ "script": script, "arguments": ["runtime-env"], "runtime_port": environment.get("NEMOCLAW_DASHBOARD_PORT"), @@ -431,7 +434,6 @@ with tempfile.TemporaryDirectory() as root: try: control._hermes_preflight(reader, supervisor) verified_preflight_steps = list(preflight_steps) - real_read_stable_file = reader.read_stable_file real_monotonic = control.time.monotonic real_sleep = control.time.sleep @@ -453,7 +455,6 @@ with tempfile.TemporaryDirectory() as root: ] finally: reader.read_stable_file = real_read_stable_file - persistent_preflight_reads = [] fake_clock[0] = 0.0 def persistent_preflight_read(identity, _name, _limit): @@ -471,7 +472,6 @@ with tempfile.TemporaryDirectory() as root: ] finally: reader.read_stable_file = real_read_stable_file - identity_change_reads = [] fake_clock[0] = 0.0 real_capture = reader.capture @@ -495,28 +495,27 @@ with tempfile.TemporaryDirectory() as root: reader.capture = real_capture control.time.monotonic = real_monotonic control.time.sleep = real_sleep + control._validate_managed_gateway_environment = real_runtime_validator + control._hermes_preflight(reader, supervisor) + supervisor_environment_path = os.path.join(proc_root, "40", "environ") + with open(supervisor_environment_path, "wb") as stream: stream.write( + b"PATH=/usr/bin\0HERMES_ENABLE_PROJECT_PLUGINS=1\0" + ) + try: + with contextlib.redirect_stderr(io.StringIO()): + control._hermes_preflight(reader, supervisor) + real_preflight_validation = ["accepted", "accepted"] + except control.ControlError as error: + real_preflight_validation = ["accepted", error.code] + finally: + with open(supervisor_environment_path, "wb") as stream: stream.write( + b"PATH=/usr/bin\0NEMOCLAW_DASHBOARD_PORT=18789\0NEMOCLAW_HERMES_API_PORT=8645\0" + ) finally: control._run_fixed_validator = real_validator - control._validate_runtime_environment = real_runtime_validator + control._validate_managed_gateway_environment = real_runtime_validator control._verify_locked_hermes_hash = real_hash_check - real_subprocess_run = control.subprocess.run - control.subprocess.run = lambda *_args, **_kwargs: (_ for _ in ()).throw( - AssertionError("runtime boundary must not exec with untrusted env") - ) - try: - control._validate_runtime_environment( - sys.argv[2], - { - "LD_PRELOAD": "/tmp/attacker.so", - "SAFE": "1", "HERMES_HOME": "/sandbox/.hermes", "HERMES_BUNDLED_PLUGINS": "/opt/hermes/plugins", - "HERMES_LAZY_INSTALL_TARGET": "/sandbox/.hermes/lazy-packages", - }, - ) - runtime_validation = "in-process" - finally: - control.subprocess.run = real_subprocess_run - write_process( proc_root, namespace_path, @@ -1210,7 +1209,7 @@ with tempfile.TemporaryDirectory() as root: persistent_preflight_retry, changed_preflight_identity, ], - "runtime_validation": runtime_validation, + "real_preflight_validation": real_preflight_validation, "missing_supervisor": missing_supervisor, "appearing_supervisor": appearing_supervisor, "unreadable_process": unreadable_process, @@ -1301,7 +1300,7 @@ describe("managed gateway root control", () => { it("pins the OpenShell process tree, rejects ambiguity/reuse, and proves restart/recover", () => { const result = spawnSync("python3", ["-c", PROCESS_HARNESS, HELPER, BOUNDARY_VALIDATOR], { encoding: "utf-8", - timeout: 10_000, + timeout: 15_000, env: { ...process.env, HERMES_LAZY_INSTALL_TARGET: "/sandbox/.hermes/lazy-packages", @@ -1346,7 +1345,7 @@ describe("managed gateway root control", () => { ["SUPERVISOR_UNAVAILABLE", true, 1], ["SUPERVISOR_UNAVAILABLE", 0], ], - runtime_validation: "in-process", + real_preflight_validation: ["accepted", "SECRET_BOUNDARY_REFUSED"], missing_supervisor: "SUPERVISOR_NOT_RUNNING", appearing_supervisor: "SUPERVISOR_UNAVAILABLE", unreadable_process: "SUPERVISOR_DISCOVERY_PENDING", diff --git a/test/inference/managed/managed-image-capability-union.test.ts b/test/inference/managed/managed-image-capability-union.test.ts index 1a36918dad8..7095537b64e 100644 --- a/test/inference/managed/managed-image-capability-union.test.ts +++ b/test/inference/managed/managed-image-capability-union.test.ts @@ -75,7 +75,9 @@ describe("managed-image capability union", () => { }); expect(MANAGED_IMAGE_HERMES_SUPPORTED_PLATFORMS).toEqual(hermesPlatforms); expect(MANAGED_IMAGE_HERMES_NEUTRAL_PLATFORMS).toEqual([ + "a2a", "bluebubbles", + "buzz", "dingtalk", "discord", "email", diff --git a/test/install/seed-reviewed-npm-cache.test.ts b/test/install/seed-reviewed-npm-cache.test.ts index 1b28baf12e7..2fa57a434aa 100644 --- a/test/install/seed-reviewed-npm-cache.test.ts +++ b/test/install/seed-reviewed-npm-cache.test.ts @@ -217,7 +217,7 @@ describe("reviewed npm cache seed", () => { vi.stubEnv("NPM_TRACE", tracePath); await expect(seedReviewedNpmCache(request(input))).rejects.toThrow( - "reviewed npm cache seed does not support npm@99.0.0; expected npm@10.9.4, npm@10.9.8, or npm@11.17.0", + "reviewed npm cache seed does not support npm@99.0.0; expected npm@10.9.4, npm@10.9.8, npm@11.17.0, or npm@11.18.0", ); expect(fs.readFileSync(tracePath, "utf8")).toBe("--version\n"); }); diff --git a/test/install/update-hermes-agent-script.test.ts b/test/install/update-hermes-agent-script.test.ts index e2d8041111f..6855962d276 100644 --- a/test/install/update-hermes-agent-script.test.ts +++ b/test/install/update-hermes-agent-script.test.ts @@ -16,7 +16,7 @@ const HERMES_BASE_DOCKERFILE = path.join( "Dockerfile.base", ); const HERMES_MANIFEST = path.join(import.meta.dirname, "../..", "agents", "hermes", "manifest.yaml"); -const TARGET_TAG = "v2026.7.20"; +const TARGET_TAG = "v2026.8.27"; const CURRENT_INSTALLED_BASE = [ "# Calver tag v2026.6.5 = Hermes Agent v0.16.0.", @@ -90,7 +90,7 @@ printf 'fake archive' > "$output" ); writeExecutable( path.join(fakeBin, "tar"), - "#!/usr/bin/env bash\nprintf 'version = \"0.19.0\"\\n'\n", + "#!/usr/bin/env bash\nprintf 'version = \"0.20.6\"\\n'\n", ); writeExecutable(path.join(fakeBin, "npm"), "#!/usr/bin/env bash\nprintf 'sha512-test\\n'\n"); writeExecutable( @@ -109,7 +109,7 @@ esac set -euo pipefail printf '%s|%s\\n' "\${NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF:-}" "$*" >> "$FAKE_NEMOHERMES_LOG" if [[ "$*" == "hermes exec -- hermes --version" ]]; then - printf '0.19.0\\n' + printf '0.20.6\\n' fi `, ); @@ -133,7 +133,7 @@ fi expect(run.status, `${run.stdout}\n${run.stderr}`).toBe(0); expect(fs.readFileSync(dockerLog, "utf8")).toContain(`tag ${baseRef} ${pinnedRef}`); expect(fs.readFileSync(nemohermesLog, "utf8")).toContain(`${pinnedRef}|hermes rebuild`); - expect(run.stdout).toContain("OK: sandbox reports Hermes Agent v0.19.0"); + expect(run.stdout).toContain("OK: sandbox reports Hermes Agent v0.20.6"); // #9979: the curl fetch must fail closed on a protocol-downgrade redirect. const curlArgv = fs.readFileSync(curlLog, "utf8").trim(); const curlCallCount = curlArgv.split("\n").length; diff --git a/test/mcp/mcp-bridge-servers.test.ts b/test/mcp/mcp-bridge-servers.test.ts index e5773790c08..b7853d53f17 100644 --- a/test/mcp/mcp-bridge-servers.test.ts +++ b/test/mcp/mcp-bridge-servers.test.ts @@ -1009,14 +1009,19 @@ describe("authenticated MCP live fixtures", () => { id: "call_hermes_tool_search", function: { name: "tool_search", - arguments: JSON.stringify({ query: deferredToolName }), + arguments: JSON.stringify({ queries: [deferredToolName] }), }, }); const missedSearch = await call([ { role: "tool", tool_call_id: "call_hermes_tool_search", - content: '{"matches":[{"name":"some_other_tool"}]}', + content: JSON.stringify({ + queries: [deferredToolName], + total_available: 1, + results: [{ query: deferredToolName, matches: ["some_other_tool"] }], + tools: { some_other_tool: { description: "Another tool" } }, + }), }, ]); expect(missedSearch).toMatchObject({ @@ -1032,7 +1037,12 @@ describe("authenticated MCP live fixtures", () => { { role: "tool", tool_call_id: "call_hermes_tool_search", - content: JSON.stringify({ query: deferredToolName, matches: [] }), + content: JSON.stringify({ + queries: [deferredToolName], + total_available: 1, + results: [{ query: deferredToolName, matches: [] }], + tools: {}, + }), }, ]); expect(echoedSearchQueryWithoutMatch).toMatchObject({ @@ -1047,13 +1057,18 @@ describe("authenticated MCP live fixtures", () => { const searchResult = { role: "tool", tool_call_id: "call_hermes_tool_search", - content: JSON.stringify({ matches: [{ name: deferredToolName }] }), + content: JSON.stringify({ + queries: [deferredToolName], + total_available: 1, + results: [{ query: deferredToolName, matches: [deferredToolName] }], + tools: { [deferredToolName]: { description: "Deferred echo" } }, + }), }; const describeBody = await call([searchResult]); expect(describeBody.choices[0].message.tool_calls[0]).toMatchObject({ function: { name: "tool_describe", - arguments: JSON.stringify({ name: deferredToolName }), + arguments: JSON.stringify({ names: [deferredToolName] }), }, }); const wrongDescription = await call([ @@ -1061,7 +1076,7 @@ describe("authenticated MCP live fixtures", () => { { role: "tool", tool_call_id: "call_hermes_tool_describe", - content: JSON.stringify({ name: deferredToolName, parameters: {} }), + content: JSON.stringify({ tools: { [deferredToolName]: {} } }), }, ]); expect(wrongDescription).toMatchObject({ @@ -1073,8 +1088,12 @@ describe("authenticated MCP live fixtures", () => { role: "tool", tool_call_id: "call_hermes_tool_describe", content: JSON.stringify({ - name: deferredToolName, - parameters: { properties: { challenge: { type: "string" } } }, + tools: { + [deferredToolName]: { + description: "Deferred echo", + parameters: { properties: { challenge: { type: "string" } } }, + }, + }, }), }; const callBody = await call([searchResult, descriptionResult]); diff --git a/test/onboarding/effective-policy-contracts.test.ts b/test/onboarding/effective-policy-contracts.test.ts index 356c0c1dad1..8aa066e4446 100644 --- a/test/onboarding/effective-policy-contracts.test.ts +++ b/test/onboarding/effective-policy-contracts.test.ts @@ -461,6 +461,18 @@ describe("effective built-in policy contracts", () => { ); expect(browserHosts.length > 0).toBe(presetName === "nous-browser"); }); + + const browser = requireNetworkPolicy(effective, "nous_browser"); + expect(binaries(browser)).toEqual( + expect.arrayContaining([ + "/sandbox/.hermes/node/bin/node*", + "/sandbox/.hermes/node/bin/npx*", + "/sandbox/.hermes/node/bin/agent-browser*", + ]), + ); + expect(binaries(browser).filter((binary) => binary.startsWith("/sandbox/.hermes-data/"))).toEqual( + [], + ); }); it("keeps OpenClaw messaging credentials and WebSockets inside inspected endpoints", () => { diff --git a/test/package-contract/blueprint-external-target-plan.test.ts b/test/package-contract/blueprint-external-target-plan.test.ts index 9712c196073..98d243ed703 100644 --- a/test/package-contract/blueprint-external-target-plan.test.ts +++ b/test/package-contract/blueprint-external-target-plan.test.ts @@ -51,6 +51,12 @@ function npmEnvironment( }; } +function rootPackagePackEnvironment( + environment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return { ...npmEnvironment(environment), NEMOCLAW_INSTALLING: "1" }; +} + function writeRuntimeProbe(probePath: string): void { fs.writeFileSync( probePath, @@ -169,6 +175,12 @@ type ProbeEvidence = Readonly<{ }>; describe("packaged Blueprint Runner npm cache", () => { + it("marks root package packing as installer-owned so prepare cannot race the build", () => { + const environment = rootPackagePackEnvironment({ NEMOCLAW_INSTALLING: "" }); + + expect(environment.NEMOCLAW_INSTALLING).toBe("1"); + }); + it("uses the populated trusted runner cache before npm exec's default", () => { const runnerTemp = path.join(os.tmpdir(), "trusted-runner"); const environment = npmEnvironment( @@ -227,7 +239,7 @@ describe.sequential("packaged Blueprint Runner external target", () => { const pack = spawnSync( "npm", ["pack", "--ignore-scripts", "--silent", "--pack-destination", archiveRoot], - { cwd: REPOSITORY_ROOT, encoding: "utf8", env: npmEnvironment() }, + { cwd: REPOSITORY_ROOT, encoding: "utf8", env: rootPackagePackEnvironment() }, ); assertCommandSucceeded(pack, "root package archive creation"); const archives = fs.readdirSync(archiveRoot).filter((entry) => entry.endsWith(".tgz")); diff --git a/test/package-contract/lifecycle-api.test.ts b/test/package-contract/lifecycle-api.test.ts index 02b32b5f9ae..e90976fbc7c 100644 --- a/test/package-contract/lifecycle-api.test.ts +++ b/test/package-contract/lifecycle-api.test.ts @@ -206,7 +206,7 @@ void (async () => { }, agent: { name: "hermes", - version: "0.19.0", + version: "0.20.6", configurationFingerprint: digest("4"), health: { state: "reachable", statusCode: 200 }, }, @@ -315,7 +315,7 @@ const observer: OpenShellHermesAgentObserver = { }, agent: { name: "hermes", - version: "0.19.0", + version: "0.20.6", configurationFingerprint: digest("4"), health: { state: "reachable", statusCode: 200 }, }, diff --git a/test/platform/images/base-image-resolver-helper.test.ts b/test/platform/images/base-image-resolver-helper.test.ts index abda01560f2..9948941562b 100644 --- a/test/platform/images/base-image-resolver-helper.test.ts +++ b/test/platform/images/base-image-resolver-helper.test.ts @@ -154,8 +154,13 @@ exit 2`); "-I", "-c", ]); - expect(runtimeProbe?.at(-1)).toContain("or sys.exit(1)"); - expect(runtimeProbe?.at(-1)).not.toContain("assert "); + const probe = runtimeProbe?.at(-1) ?? ""; + const initializer = "mcp_tool._ensure_mcp_sdk() or sys.exit(1)"; + expect(probe).toContain("or sys.exit(1)"); + expect(probe).not.toContain("assert "); + expect(probe).toContain(initializer); + expect(probe.indexOf(initializer)).toBeLessThan(probe.indexOf("_MCP_AVAILABLE")); + expect(probe.indexOf(initializer)).toBeLessThan(probe.indexOf("_MCP_HTTP_AVAILABLE")); }); it("rejects a Hermes candidate that has MCP but lacks ACP and builds locally", () => { diff --git a/test/repository/vitest-watch-triggers.test.ts b/test/repository/vitest-watch-triggers.test.ts index 705872331e8..5813c3fa6fe 100644 --- a/test/repository/vitest-watch-triggers.test.ts +++ b/test/repository/vitest-watch-triggers.test.ts @@ -54,11 +54,12 @@ const OPAQUE_INPUTS = [ "tools/e2e/brev-launchable-e2e.sh", "managed-inference/models/example.yaml", "managed-inference/recipes/vllm.example.managed-cluster.v1.yaml", - "internal/security-reviews/hermes-0.19.0-dependency-review.md", + "internal/security-reviews/hermes-0.20.6-dependency-review.md", ".github/actions/resolve-hermes-base-image/action.yaml", ".github/actions/resolve-reviewed-hermes-platform/action.yaml", "Dockerfile", "agents/hermes/Dockerfile.base", + "agents/hermes/patch-session-list-preview.py", "agents/hermes/Dockerfile", "agents/langchain-deepagents-code/Dockerfile", "agents/hermes/policy-additions.yaml", @@ -197,9 +198,7 @@ describe("Vitest opaque-input watch triggers", () => { "src/lib/inference/serving/resolver.test.ts", "test/inference/managed/managed-inference-catalog-compiler.test.ts", ]); - expect(triggeredBy("internal/security-reviews/hermes-0.19.0-dependency-review.md")).toEqual([ - "test/agents/hermes/hermes-dependency-review.test.ts", - ]); + expect(triggeredBy("internal/security-reviews/hermes-0.20.6-dependency-review.md")).toEqual([]); expect(triggeredBy(".github/actions/resolve-hermes-base-image/action.yaml")).toEqual([ "test/platform/images/base-image-resolver-helper.test.ts", ]); @@ -213,12 +212,18 @@ describe("Vitest opaque-input watch triggers", () => { "src/lib/sandbox/optimized-build-context-copy-sources.test.ts", ]); expect(triggeredBy("agents/hermes/Dockerfile.base")).toEqual([ - "test/agents/hermes/hermes-dependency-review.test.ts", "test/agents/hermes/hermes-share-mount-deps.test.ts", "test/inference/managed/managed-image-publication-workflow.test.ts", "test/runtime/sandbox/sandbox-provisioning.test.ts", ]); + expect(triggeredBy("agents/hermes/patch-session-list-preview.py")).toEqual([ + "test/agents/hermes/hermes-session-list-preview-patch.test.ts", + ]); + expect(triggeredBy("nemoclaw-blueprint/policies/presets/nous-browser.yaml")).toEqual([ + "test/onboarding/effective-policy-contracts.test.ts", + ]); expect(triggeredBy("agents/hermes/Dockerfile")).toEqual([ + "src/lib/onboard/experimental/hermes-portable-build-context.test.ts", "src/lib/onboard/managed-startup-profile.test.ts", "test/agents/hermes/hermes-mcp-runtime-capability.test.ts", ]); diff --git a/test/runtime/sandbox/sandbox-provisioning.test.ts b/test/runtime/sandbox/sandbox-provisioning.test.ts index 53a173e258e..253328971dd 100644 --- a/test/runtime/sandbox/sandbox-provisioning.test.ts +++ b/test/runtime/sandbox/sandbox-provisioning.test.ts @@ -26,6 +26,12 @@ const DOCKERFILE_BASE = path.join(ROOT, "Dockerfile.base"); const DOCKERFILE_SANDBOX = path.join(ROOT, "test", "Dockerfile.sandbox"); const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); const HERMES_DOCKERFILE_BASE = path.join(ROOT, "agents", "hermes", "Dockerfile.base"); +const HERMES_FINALIZE_IMAGE_LAYOUT = path.join( + ROOT, + "agents", + "hermes", + "finalize-image-layout.sh", +); const DEEPAGENTS_DOCKERFILE_BASE = path.join( ROOT, "agents", @@ -1106,10 +1112,21 @@ describe("Hermes sandbox provisioning", () => { fs.writeFileSync(path.join(hermesDir, "config.yaml"), "model: test\n"); fs.writeFileSync(path.join(hermesDir, ".env"), "TOKEN=test\n"); } - const command = dockerRunCommandBetween(dockerfile, startMarker, endMarker).replaceAll( - "/root/.cache/pip", - path.join(tmp, "root-cache", "pip"), - ); + const finalizeImageLayout = path.join(tmp, "finalize-image-layout.sh"); + fs.copyFileSync(HERMES_FINALIZE_IMAGE_LAYOUT, finalizeImageLayout); + const finalizeImageLayoutSha256 = dockerfile.match( + /^ARG NEMOCLAW_HERMES_FINALIZE_IMAGE_LAYOUT_SHA256=([a-f0-9]{64})$/mu, + )?.[1] ?? ""; + const command = dockerRunCommandBetween(dockerfile, startMarker, endMarker) + .replaceAll("/root/.cache/pip", path.join(tmp, "root-cache", "pip")) + .replaceAll( + "/opt/nemoclaw-hermes-config/finalize-image-layout.sh", + finalizeImageLayout, + ) + .replaceAll( + "$NEMOCLAW_HERMES_FINALIZE_IMAGE_LAYOUT_SHA256", + finalizeImageLayoutSha256, + ); const result = runDockerShell(command, sandboxRoot); return { ...result, tmp, sandboxRoot }; } @@ -1274,7 +1291,10 @@ describe("Hermes sandbox provisioning", () => { ]; try { runs.forEach((run) => { - expect(run.result.status).toBe(0); + expect( + run.result.status, + [run.result.stderr, run.result.error?.message].filter(Boolean).join("\n"), + ).toBe(0); const hermesDir = path.join(run.sandboxRoot, ".hermes"); expect((fs.statSync(hermesDir).mode & 0o7777).toString(8)).toBe("3770"); expect(["logs", "logs/curator", "cache", "hooks", "image_cache", "audio_cache", "platforms"].every((dir) => diff --git a/test/runtime/sandbox/sandbox-rlimit-hooks.test.ts b/test/runtime/sandbox/sandbox-rlimit-hooks.test.ts index 9493b3c69a5..2fa9f38fe52 100644 --- a/test/runtime/sandbox/sandbox-rlimit-hooks.test.ts +++ b/test/runtime/sandbox/sandbox-rlimit-hooks.test.ts @@ -598,13 +598,11 @@ describe("sandbox rlimit system hooks (#2173)", () => { const rlimitLib = path.join(localLib, "sandbox-rlimits.sh"); const initLib = path.join(localLib, "sandbox-init.sh"); const validator = path.join(localLib, "validate-hermes-env-secret-boundary.py"); - const sessionListPreviewPatcher = path.join(localLib, "patch-hermes-session-list-preview.py"); const sqliteTempStorePatcher = path.join(localLib, "patch-hermes-sqlite-temp-store.py"); const discordRecoveryPatcher = path.join( localLib, "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"); @@ -637,10 +635,8 @@ describe("sandbox rlimit system hooks (#2173)", () => { copyRlimitFixture(rlimitLib); fs.writeFileSync(initLib, "# init fixture\n"); fs.writeFileSync(validator, "# validator fixture\n"); - fs.writeFileSync(sessionListPreviewPatcher, "# session list preview patcher fixture\n"); fs.writeFileSync(sqliteTempStorePatcher, "# SQLite temp store 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"); @@ -680,10 +676,6 @@ describe("sandbox rlimit system hooks (#2173)", () => { .replaceAll("/usr/local/lib/nemoclaw/sandbox-init.sh", initLib) .replaceAll("/usr/local/lib/nemoclaw/gateway-supervisor.sh", gatewaySupervisor) .replaceAll("/usr/local/lib/nemoclaw/validate-hermes-env-secret-boundary.py", validator) - .replaceAll( - "/usr/local/lib/nemoclaw/patch-hermes-session-list-preview.py", - sessionListPreviewPatcher, - ) .replaceAll( "/usr/local/lib/nemoclaw/patch-hermes-sqlite-temp-store.py", sqliteTempStorePatcher, @@ -692,10 +684,6 @@ describe("sandbox rlimit system hooks (#2173)", () => { "/usr/local/lib/nemoclaw/patch-hermes-discord-recovery-permissions.py", discordRecoveryPatcher, ) - .replaceAll( - "/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", @@ -741,7 +729,6 @@ describe("sandbox rlimit system hooks (#2173)", () => { expect(hardenedSafetyNet.mode & 0o777).toBe(0o444); expect(hardenedCiaoGuard.mode & 0o777).toBe(0o444); expect(fs.statSync(discordRecoveryPatcher).mode & 0o777).toBe(0o755); - expect(fs.statSync(profilePolicyPatcher).mode & 0o777).toBe(0o755); expect(fs.statSync(langfuseCredentialPatcher).mode & 0o777).toBe(0o444); expect(fs.statSync(mcpCredentialBoundary).mode & 0o777).toBe(0o444); expect(fs.statSync(buildMcpDigest).mode & 0o777).toBe(0o444);