From 73f88fe46fb2270af7543a570bab2bd488547f1b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 20 Jul 2026 22:11:23 -0400 Subject: [PATCH 01/24] fix(security): update OpenClaw dependency graph Signed-off-by: Julie Yaunches --- .../actions/ci-reviewed-npm-audit/action.yaml | 2 +- .../workflows/candidate-compatibility.yaml | 2 +- .github/workflows/e2e.yaml | 4 +- Dockerfile | 53 +- Dockerfile.base | 14 +- agents/hermes/Dockerfile | 2 + .../langchain-deepagents-code/Dockerfile.base | 2 +- agents/openclaw/manifest.yaml | 2 +- ci/reviewed-npm-audit.json | 58 +-- ci/reviewed-npm-lifecycle-allowlist.json | 16 +- .../openclaw-2026.7.1-dependency-review.md | 143 ++++++ .../policies/presets/weather.yaml | 2 +- nemoclaw/package.json | 2 +- scripts/audit-reviewed-npm-graph.mts | 14 +- .../check-messaging-plan-image-boundary.mts | 2 +- scripts/lib/openclaw-npm-remediation.mts | 451 ++++++++++++++++++ scripts/nemoclaw-start.sh | 4 +- scripts/patch-openclaw-chat-send.mts | 13 +- .../patch-openclaw-device-self-approval.mts | 161 ++++++- .../patch-openclaw-issue-4434-diagnostics.mts | 2 +- scripts/validate-openclaw-tool-search.mts | 1 + .../applier/build/messaging-build-applier.mts | 10 +- .../messaging/channels/discord/manifest.ts | 6 +- src/lib/messaging/channels/metadata.test.ts | 10 +- src/lib/messaging/channels/slack/manifest.ts | 6 +- src/lib/messaging/channels/teams/manifest.ts | 6 +- .../channels/whatsapp/hooks/status-health.ts | 2 +- .../messaging/channels/whatsapp/manifest.ts | 6 +- .../runtime/whatsapp-qr-compact.test.ts | 2 +- .../whatsapp/runtime/whatsapp-qr-compact.ts | 4 +- src/lib/sandbox/build-context.ts | 4 + test/e2e/live/messaging-providers.test.ts | 2 +- .../openclaw-tui-chat-correlation.test.ts | 4 +- ...ll-gateway-auth-source-contract-helpers.ts | 2 +- test/e2e/live/snapshot-credential-scanner.ts | 2 +- ...messaging-providers-runtime-proofs.test.ts | 4 +- ...in-runtime-exdev-workflow-boundary.test.ts | 2 +- ...ay-auth-contract-workflow-boundary.test.ts | 2 +- test/effective-policy-contracts.test.ts | 2 +- test/fetch-guard-patch-regression.test.ts | 46 +- test/helpers/fetch-guard-patch-harness.ts | 2 +- ...claw-device-self-approval-patch-harness.ts | 59 +++ ...penclaw-real-device-self-approval-proof.ts | 83 ++-- test/issue-4434-error-fields.test.ts | 16 +- .../messaging-build-applier-integrity.test.ts | 103 ++-- test/messaging-build-applier.test.ts | 156 +++--- test/openclaw-dependency-review.test.ts | 6 +- ...penclaw-device-self-approval-patch.test.ts | 51 +- test/openclaw-integrity-pin-suite.ts | 80 ++-- test/openclaw-lifecycle-policy.test.ts | 2 +- test/openclaw-npm-remediation.test.ts | 102 ++++ test/openclaw-optional-plugin-build.test.ts | 12 +- ...openclaw-real-patched-dist-harness.test.ts | 6 +- ...claw-tool-search-runtime-validator.test.ts | 6 +- .../msteams-message-hints-preload.test.ts | 10 +- test/sandbox-build-context.test.ts | 1 + ...plugin-runtime-exdev-workflow-boundary.mts | 2 +- ...ateway-auth-contract-workflow-boundary.mts | 2 +- 58 files changed, 1375 insertions(+), 396 deletions(-) create mode 100644 docs/security/openclaw-2026.7.1-dependency-review.md create mode 100755 scripts/lib/openclaw-npm-remediation.mts create mode 100644 test/openclaw-npm-remediation.test.ts diff --git a/.github/actions/ci-reviewed-npm-audit/action.yaml b/.github/actions/ci-reviewed-npm-audit/action.yaml index c6aec7245e4..4abc33e3a1b 100644 --- a/.github/actions/ci-reviewed-npm-audit/action.yaml +++ b/.github/actions/ci-reviewed-npm-audit/action.yaml @@ -10,7 +10,7 @@ runs: - name: Setup production-compatible Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: "22.22.2" + node-version: "22.23.1" - name: Materialize and audit reviewed npm graphs shell: bash diff --git a/.github/workflows/candidate-compatibility.yaml b/.github/workflows/candidate-compatibility.yaml index a3494742ef7..f72ae48142a 100644 --- a/.github/workflows/candidate-compatibility.yaml +++ b/.github/workflows/candidate-compatibility.yaml @@ -304,7 +304,7 @@ jobs: continue-on-error: true working-directory: candidate-source env: - DOCKER_GRPC_PROBE_IMAGE: node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d + DOCKER_GRPC_PROBE_IMAGE: node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba E2E_ARTIFACT_DIR: ${{ github.workspace }}/candidate-source/e2e-artifacts/live/openshell-gateway-auth-contract E2E_JOB: "1" E2E_TARGET_ID: openshell-gateway-auth-contract diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 0a2ba42d33e..ccd3e548db6 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -431,7 +431,7 @@ jobs: NEMOCLAW_RUN_LIVE_E2E: "1" NEMOCLAW_NON_INTERACTIVE: "1" NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.85" - DOCKER_GRPC_PROBE_IMAGE: "node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d" + DOCKER_GRPC_PROBE_IMAGE: "node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba" steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -3909,7 +3909,7 @@ jobs: shell: bash run: | set -euo pipefail - docker pull node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d + docker pull node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba - name: Remove Docker auth before release-pinned fixture if: always() diff --git a/Dockerfile b/Dockerfile index 1cf42015472..76170d83379 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest # Stage 1: Build TypeScript plugin from source -FROM node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d AS builder +FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba AS builder ENV NPM_CONFIG_AUDIT=false \ NPM_CONFIG_FUND=false \ NPM_CONFIG_UPDATE_NOTIFIER=false \ @@ -43,12 +43,12 @@ RUN ln -s /opt/nemoclaw/node_modules /opt/nemoclaw-root/node_modules \ FROM ${BASE_IMAGE} ARG BASE_IMAGE # Dependency review evidence for this runtime pin lives in -# docs/security/openclaw-2026.6.10-dependency-review.md. -ARG OPENCLAW_VERSION=2026.6.10 -ARG OPENCLAW_2026_6_10_INTEGRITY=sha512-LcooND2tBQw8A+kc1Ujltu3lg30bJ0w7XaeRy7eYzobb8BBdcW6DOGbwJL4vpj1vl9+gjRceOtlh5nh9OARcug== -ARG OPENCLAW_2026_6_10_TARBALL=https://registry.npmjs.org/openclaw/-/openclaw-2026.6.10.tgz -ARG OPENCLAW_DIAGNOSTICS_OTEL_2026_6_10_INTEGRITY=sha512-EJt0fjk4bcR3N/9u00f1pL0BJYG5yfC09DV3l6rWDmytpE2vUeBZWpx4pOmFDreGV+7DKxhCbQDgDAmvZGjLag== -ARG OPENCLAW_BRAVE_PLUGIN_2026_6_10_INTEGRITY=sha512-DDRnb4reL99O8kbISNbRFyk/xoUPYHsXG3UGikKAsVs+zIldYYA0hY0d3Z2aWoE+0vfda27mJUByCo7Xr15qdw== +# docs/security/openclaw-2026.7.1-dependency-review.md. +ARG OPENCLAW_VERSION=2026.7.1 +ARG OPENCLAW_2026_7_1_INTEGRITY=sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g== +ARG OPENCLAW_2026_7_1_TARBALL=https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz +ARG OPENCLAW_DIAGNOSTICS_OTEL_2026_7_1_INTEGRITY=sha512-XXhMifYWTgoR6yFN4T3JkHxdPvQCe8k1cNZjVIgXNmk1svCdBWuALfQQicmpemlmWwauIQuHYgBURY6k63e+rw== +ARG OPENCLAW_BRAVE_PLUGIN_2026_7_1_INTEGRITY=sha512-7Z+GZ/6K6a8LlkTsWVnAZ1hv8EarORzHQvFHD7ekcg033FGJOXYPEZSbvvE3qR9vM+vnoZplNjMZ7vFMRcvQgw== # E2E-only legacy fixture pins used by stale-sandbox/rebuild tests that # intentionally build an older OpenClaw base image before proving upgrade # behavior. Production workflows reject the fixture flag, both legacy version @@ -70,12 +70,13 @@ COPY agents/openclaw/mcporter-runtime/package-lock.json /usr/local/lib/nemoclaw/ COPY agents/openclaw/wechat-runtime/package.json /usr/local/lib/nemoclaw/wechat-runtime/package.json COPY agents/openclaw/wechat-runtime/package-lock.json /usr/local/lib/nemoclaw/wechat-runtime/package-lock.json COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts # OpenShell blocks the link-local EC2 Instance Metadata Service. Keep AWS SDK # credential chains from attempting an impossible metadata discovery path. ENV AWS_EC2_METADATA_DISABLED=true -# OpenClaw 2026.6.10 loads some generated source through jiti. Disable its +# OpenClaw 2026.7.1 loads some generated source through jiti. Disable its # filesystem transform cache so source fragments that mention provider marker # names do not persist under /tmp/jiti inside the sandbox. ENV JITI_FS_CACHE=false @@ -281,7 +282,7 @@ RUN set -eu; \ fi; \ EXPECTED_INTEGRITY=""; \ EXPECTED_TARBALL=""; \ - if [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then EXPECTED_INTEGRITY="$OPENCLAW_2026_6_10_INTEGRITY"; EXPECTED_TARBALL="$OPENCLAW_2026_6_10_TARBALL"; fi; \ + if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then EXPECTED_INTEGRITY="$OPENCLAW_2026_7_1_INTEGRITY"; EXPECTED_TARBALL="$OPENCLAW_2026_7_1_TARBALL"; fi; \ if [ "$OPENCLAW_VERSION" = "2026.3.11" ]; then EXPECTED_INTEGRITY="$OPENCLAW_2026_3_11_INTEGRITY"; EXPECTED_TARBALL="$OPENCLAW_2026_3_11_TARBALL"; fi; \ if [ "$OPENCLAW_VERSION" = "2026.4.24" ]; then EXPECTED_INTEGRITY="$OPENCLAW_2026_4_24_INTEGRITY"; EXPECTED_TARBALL="$OPENCLAW_2026_4_24_TARBALL"; fi; \ if [ -z "$EXPECTED_INTEGRITY" ]; then \ @@ -347,7 +348,7 @@ RUN set -eu; \ rm -rf /usr/local/lib/node_modules/openclaw /usr/local/bin/openclaw; \ npm install -g --no-audit --no-fund --no-progress --ignore-scripts "$OPENCLAW_PACK_PATH"; \ case "$OPENCLAW_VERSION" in \ - 2026.4.24|2026.6.10) node /usr/local/lib/node_modules/openclaw/scripts/postinstall-bundled-plugins.mjs ;; \ + 2026.4.24|2026.7.1) node /usr/local/lib/node_modules/openclaw/scripts/postinstall-bundled-plugins.mjs ;; \ 2026.3.11) ;; \ *) echo "ERROR: OpenClaw ${OPENCLAW_VERSION} has no reviewed lifecycle policy" >&2; exit 1 ;; \ esac; \ @@ -536,7 +537,7 @@ RUN set -eu; \ fi; \ fi; \ # --- Patch 2b: allow OpenShell host gateway only through web_fetch trusted env proxy --- \ - # Reviewed against openclaw@2026.6.10 dist: fetchWithWebToolsNetworkGuard \ + # Reviewed against openclaw@2026.7.1 dist: fetchWithWebToolsNetworkGuard \ # passes useEnvProxy into withTrustedEnvProxyGuardedFetchMode(resolved), and \ # the SSRF guard consumes policy.allowedHostnames to skip private-network \ # checks for an exact normalized hostname. hostnameAllowlist only gates \ @@ -572,22 +573,22 @@ RUN set -eu; \ fi; \ fi; \ # --- Patch 4: route unconfigured strict fetches through the sandbox egress proxy (#4687) --- \ - # Reviewed against openclaw@2026.6.10 dist fetch-guard: the STRICT-mode \ + # Reviewed against openclaw@2026.7.1 dist fetch-guard: the STRICT-mode \ # managed-proxy gate is `mode === GUARDED_FETCH_MODE.STRICT && \ - # isManagedProxyActive() && hasProxyEnvConfigured()`. Extend activation to \ - # OPENSHELL_SANDBOX=1 only for fetches with no explicit dispatcherPolicy so \ + # isManagedProxyActive()`. Extend activation to OPENSHELL_SANDBOX=1 only \ + # for fetches with no explicit dispatcherPolicy so \ # the per-request direct dispatcher reuses the env proxy (EnvHttpProxyAgent) \ # like the managed-proxy path already does; explicit-proxy / direct dispatcher \ # policies and out-of-sandbox behavior are unchanged. \ - mp_files="$(grep -RIlF --include='*.js' 'const canUseManagedProxy = mode === GUARDED_FETCH_MODE.STRICT && isManagedProxyActive() && hasProxyEnvConfigured();' "$OC_DIST" || true)"; \ + mp_files="$(grep -RIlF --include='*.js' 'const isStrictManagedProxyActive = mode === GUARDED_FETCH_MODE.STRICT && isManagedProxyActive();' "$OC_DIST" || true)"; \ if [ -n "$mp_files" ]; then \ patched_managed_proxy=0; \ for f in $mp_files; do \ if grep -q 'nemoclaw: route unconfigured strict fetch' "$f"; then \ echo "INFO: Patch 4 already present in $f"; \ else \ - sed -i -E 's#const canUseManagedProxy = mode === GUARDED_FETCH_MODE\.STRICT \&\& isManagedProxyActive\(\) \&\& hasProxyEnvConfigured\(\);#const canUseManagedProxy = mode === GUARDED_FETCH_MODE.STRICT \&\& (isManagedProxyActive() || (process.env.OPENSHELL_SANDBOX === "1" \&\& !params.dispatcherPolicy)) \&\& hasProxyEnvConfigured(); /* nemoclaw: route unconfigured strict fetch through sandbox egress proxy, see Dockerfile */#' "$f"; \ - grep -Fq 'process.env.OPENSHELL_SANDBOX === "1" && !params.dispatcherPolicy' "$f" \ + sed -i -E 's#const isStrictManagedProxyActive = mode === GUARDED_FETCH_MODE\.STRICT \&\& isManagedProxyActive\(\);#const isStrictManagedProxyActive = mode === GUARDED_FETCH_MODE.STRICT \&\& (isManagedProxyActive() || (process.env.OPENSHELL_SANDBOX === "1" \&\& !dispatcherPolicy)); /* nemoclaw: route unconfigured strict fetch through sandbox egress proxy, see Dockerfile */#' "$f"; \ + grep -Fq 'process.env.OPENSHELL_SANDBOX === "1" && !dispatcherPolicy' "$f" \ || patch_fail "Patch 4 verification failed for $f"; \ patched_managed_proxy=1; \ fi; \ @@ -596,7 +597,7 @@ RUN set -eu; \ echo "INFO: Patch 4 applied to OpenClaw ${OC_VERSION} managed-proxy strict-fetch activation"; \ fi; \ else \ - managed_proxy_refs="$(grep -RIlE --include='*.js' 'canUseManagedProxy|isManagedProxyActive' "$OC_DIST" || true)"; \ + managed_proxy_refs="$(grep -RIlE --include='*.js' 'canUseManagedProxy|isStrictManagedProxyActive' "$OC_DIST" || true)"; \ if [ -z "$managed_proxy_refs" ]; then \ echo "INFO: OpenClaw ${OC_VERSION} has no managed-proxy strict-fetch gate; Patch 4 not needed"; \ else \ @@ -606,7 +607,7 @@ RUN set -eu; \ fi; \ fi; \ # --- Patch 6: cron model-provider preflight opts into trusted env-proxy mode --- \ - # Reviewed against openclaw@2026.6.10 dist: the cron isolated-agent preflight \ + # Reviewed against openclaw@2026.7.1 dist: the cron isolated-agent preflight \ # (`probeLocalProviderEndpoint`) calls `fetchWithSsrFGuard` with \ # `auditContext: "cron-model-provider-preflight"` and a narrow hostname-allowlist \ # SsrFPolicy from `buildLocalProviderSsrFPolicy`, but does not pass a `mode`. \ @@ -716,7 +717,7 @@ RUN set -eu; \ if grep -REq --include='*.js' 'DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = (1e4|15e3)' "$OC_DIST"; then echo "ERROR: Patch 5 left a short handshake-timeout constant" >&2; exit 1; fi; \ if ! grep -REq --include='*.js' 'DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 6e4' "$OC_DIST"; then echo "ERROR: Patch 5 did not find patched 6e4 constant" >&2; exit 1; fi -# Patch OpenClaw chat.send gateway behavior for OpenClaw 2026.6.10. +# Patch OpenClaw chat.send gateway behavior for OpenClaw 2026.7.1. # # OpenClaw can accept rapid TUI/WebChat chat.send requests and then emit a # terminal chat event with state="final" but no assistant message for the later @@ -732,7 +733,7 @@ RUN set -eu; \ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-chat-send.mts \ /usr/local/lib/node_modules/openclaw/dist -# Keep OpenClaw 2026.6.10 scope-upgrade approvals inside the gateway's +# Keep OpenClaw 2026.7.1 scope-upgrade approvals inside the gateway's # canonical locked pairing writer (#4462). The upstream devices CLI otherwise # asks for the very scopes it is trying to approve, so the handshake fails # before device.pair.approve runs and its operator.admin retry fails likewise. @@ -749,7 +750,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-devic # Patch OpenClaw TUI unreachable-inference diagnostics for #4434. # -# OpenClaw 2026.6.10 formats sandbox inference egress failures as either generic +# OpenClaw 2026.7.1 formats sandbox inference egress failures as either generic # `TypeError: fetch failed` or `LLM request timed out.` messages, which leave the # TUI without the required HTTP/cause, gateway/upstream reporting layer, and # recovery hint fields. This version-scoped shim enriches only those reviewed @@ -773,7 +774,7 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-n /usr/local/lib/node_modules/openclaw/dist # Run the compact tool catalog shim for OpenClaw selection runtimes that still -# need it. OpenClaw 2026.6.10 ships a built-in catalog surface, so the script +# need it. OpenClaw 2026.7.1 ships a built-in catalog surface, so the script # skips cleanly after classifying the compiled selection-*.js shape. # hadolint ignore=DL3059 RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts \ @@ -923,7 +924,7 @@ ARG NEMOCLAW_WEB_SEARCH_PROVIDER=brave ARG NEMOCLAW_OPENCLAW_OTEL=0 # The default local OTEL endpoint is intentionally the single host-gateway # collector path covered by the openclaw-diagnostics-otel-local policy preset. -# @openclaw/diagnostics-otel@2026.6.10 exports through OpenTelemetry's OTLP +# @openclaw/diagnostics-otel@2026.7.1 exports through OpenTelemetry's OTLP # trace exporter path, not OpenClaw web_fetch, so Patch 2b's host gateway # exception remains scoped to user-requested web_fetch proxy calls. ARG NEMOCLAW_OPENCLAW_OTEL_ENDPOINT=http://host.openshell.internal:4318 @@ -1034,8 +1035,8 @@ RUN set -eu; \ expected_integrity=""; \ expected_tarball=""; \ case "$plugin_spec" in \ - "@openclaw/diagnostics-otel@2026.6.10") expected_integrity="$OPENCLAW_DIAGNOSTICS_OTEL_2026_6_10_INTEGRITY"; expected_tarball="https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.6.10.tgz" ;; \ - "@openclaw/brave-plugin@2026.6.10") expected_integrity="$OPENCLAW_BRAVE_PLUGIN_2026_6_10_INTEGRITY"; expected_tarball="https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.10.tgz" ;; \ + "@openclaw/diagnostics-otel@2026.7.1") expected_integrity="$OPENCLAW_DIAGNOSTICS_OTEL_2026_7_1_INTEGRITY"; expected_tarball="https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.7.1.tgz" ;; \ + "@openclaw/brave-plugin@2026.7.1") expected_integrity="$OPENCLAW_BRAVE_PLUGIN_2026_7_1_INTEGRITY"; expected_tarball="https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.7.1.tgz" ;; \ esac; \ if [ -z "$expected_integrity" ]; then \ echo "ERROR: OpenClaw plugin ${plugin_spec} has no committed npm integrity pin" >&2; exit 1; \ diff --git a/Dockerfile.base b/Dockerfile.base index b96754c334f..05d59607aca 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -50,7 +50,7 @@ # by OpenClaw CLI version bumps or the weekly docker-pin-check. # ──────────────────────────────────────────────────────────────────────── -FROM node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d +FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba # OpenShell blocks the link-local EC2 Instance Metadata Service. Keep AWS SDK # credential chains from attempting an impossible metadata discovery path. @@ -203,10 +203,10 @@ RUN chmod 444 /usr/local/lib/nemoclaw/sandbox-rlimits.sh \ # the base image on push to main, or use workflow_dispatch on base-image.yaml # with the openclaw_version input for a one-off build without editing this file. # Dependency review evidence for this runtime pin lives in -# docs/security/openclaw-2026.6.10-dependency-review.md. -ARG OPENCLAW_VERSION=2026.6.10 -ARG OPENCLAW_2026_6_10_INTEGRITY=sha512-LcooND2tBQw8A+kc1Ujltu3lg30bJ0w7XaeRy7eYzobb8BBdcW6DOGbwJL4vpj1vl9+gjRceOtlh5nh9OARcug== -ARG OPENCLAW_2026_6_10_TARBALL=https://registry.npmjs.org/openclaw/-/openclaw-2026.6.10.tgz +# docs/security/openclaw-2026.7.1-dependency-review.md. +ARG OPENCLAW_VERSION=2026.7.1 +ARG OPENCLAW_2026_7_1_INTEGRITY=sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g== +ARG OPENCLAW_2026_7_1_TARBALL=https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz # E2E-only legacy fixture pins used by stale-sandbox/rebuild tests that # intentionally build an older OpenClaw base image before proving upgrade # behavior. Production workflows reject the fixture flag, both legacy version @@ -259,7 +259,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep fi; \ EXPECTED_INTEGRITY=""; \ EXPECTED_TARBALL=""; \ - if [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then EXPECTED_INTEGRITY="$OPENCLAW_2026_6_10_INTEGRITY"; EXPECTED_TARBALL="$OPENCLAW_2026_6_10_TARBALL"; fi; \ + if [ "$OPENCLAW_VERSION" = "2026.7.1" ]; then EXPECTED_INTEGRITY="$OPENCLAW_2026_7_1_INTEGRITY"; EXPECTED_TARBALL="$OPENCLAW_2026_7_1_TARBALL"; fi; \ if [ "$OPENCLAW_VERSION" = "2026.3.11" ]; then EXPECTED_INTEGRITY="$OPENCLAW_2026_3_11_INTEGRITY"; EXPECTED_TARBALL="$OPENCLAW_2026_3_11_TARBALL"; fi; \ if [ "$OPENCLAW_VERSION" = "2026.4.24" ]; then EXPECTED_INTEGRITY="$OPENCLAW_2026_4_24_INTEGRITY"; EXPECTED_TARBALL="$OPENCLAW_2026_4_24_TARBALL"; fi; \ if [ -z "$EXPECTED_INTEGRITY" ]; then \ @@ -274,7 +274,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_PACK_PATH")"; \ npm install -g --ignore-scripts "$OPENCLAW_PACK_PATH" \ && case "$OPENCLAW_VERSION" in \ - 2026.4.24|2026.6.10) node /usr/local/lib/node_modules/openclaw/scripts/postinstall-bundled-plugins.mjs ;; \ + 2026.4.24|2026.7.1) node /usr/local/lib/node_modules/openclaw/scripts/postinstall-bundled-plugins.mjs ;; \ 2026.3.11) ;; \ *) echo "Error: OpenClaw ${OPENCLAW_VERSION} has no reviewed lifecycle policy"; exit 1 ;; \ esac \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 7eafd5fa18f..4796f76aee1 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -107,10 +107,12 @@ COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-co COPY src/lib/tool-disclosure.ts /src/lib/tool-disclosure.ts COPY src/lib/messaging/ /src/lib/messaging/ COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ && find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} + \ && chmod 444 /src/lib/tool-disclosure.ts \ && chmod 444 /scripts/lib/reviewed-npm-archive.mts \ + /scripts/lib/openclaw-npm-remediation.mts \ && chmod -R a+rX /src/lib/messaging # Copy blueprint (shared infrastructure) diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 4fa8f07ba39..f20410fa391 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -7,7 +7,7 @@ # Node for NemoClaw build-time config generation, Python, shell tools, and a # hash-locked deepagents-code install with the NVIDIA provider extra. -FROM node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d +FROM node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba ENV DEBIAN_FRONTEND=noninteractive \ VIRTUAL_ENV=/opt/venv \ diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index 1eefe655b34..fb4d077bd56 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -19,7 +19,7 @@ homepage: "https://openclaw.ai" install_method: npm # npm install -g openclaw@ binary_path: /usr/local/bin/openclaw version_command: "openclaw --version" -expected_version: "2026.6.10" +expected_version: "2026.7.1" version_scheme: calendar gateway_command: "openclaw gateway run" diff --git a/ci/reviewed-npm-audit.json b/ci/reviewed-npm-audit.json index 3737c93c26d..03dcee9b642 100644 --- a/ci/reviewed-npm-audit.json +++ b/ci/reviewed-npm-audit.json @@ -1,14 +1,14 @@ { "schemaVersion": 1, - "nodeVersion": "22.22.2", + "nodeVersion": "22.23.1", "severityThreshold": "high", "artifactDirectory": "coverage/reviewed-npm-audit", "archivePackages": [ { - "label": "OpenClaw 2026.6.10", - "packageSpec": "openclaw@2026.6.10", - "integrity": "sha512-LcooND2tBQw8A+kc1Ujltu3lg30bJ0w7XaeRy7eYzobb8BBdcW6DOGbwJL4vpj1vl9+gjRceOtlh5nh9OARcug==", - "tarballUrl": "https://registry.npmjs.org/openclaw/-/openclaw-2026.6.10.tgz" + "label": "OpenClaw 2026.7.1", + "packageSpec": "openclaw@2026.7.1", + "integrity": "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g==", + "tarballUrl": "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz" }, { "label": "Codex ACP 0.11.1", @@ -17,40 +17,40 @@ "tarballUrl": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.11.1.tgz" }, { - "label": "OpenClaw diagnostics OTEL 2026.6.10", - "packageSpec": "@openclaw/diagnostics-otel@2026.6.10", - "integrity": "sha512-EJt0fjk4bcR3N/9u00f1pL0BJYG5yfC09DV3l6rWDmytpE2vUeBZWpx4pOmFDreGV+7DKxhCbQDgDAmvZGjLag==", - "tarballUrl": "https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.6.10.tgz" + "label": "OpenClaw diagnostics OTEL 2026.7.1", + "packageSpec": "@openclaw/diagnostics-otel@2026.7.1", + "integrity": "sha512-XXhMifYWTgoR6yFN4T3JkHxdPvQCe8k1cNZjVIgXNmk1svCdBWuALfQQicmpemlmWwauIQuHYgBURY6k63e+rw==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.7.1.tgz" }, { - "label": "OpenClaw Brave plugin 2026.6.10", - "packageSpec": "@openclaw/brave-plugin@2026.6.10", - "integrity": "sha512-DDRnb4reL99O8kbISNbRFyk/xoUPYHsXG3UGikKAsVs+zIldYYA0hY0d3Z2aWoE+0vfda27mJUByCo7Xr15qdw==", - "tarballUrl": "https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.10.tgz" + "label": "OpenClaw Brave plugin 2026.7.1", + "packageSpec": "@openclaw/brave-plugin@2026.7.1", + "integrity": "sha512-7Z+GZ/6K6a8LlkTsWVnAZ1hv8EarORzHQvFHD7ekcg033FGJOXYPEZSbvvE3qR9vM+vnoZplNjMZ7vFMRcvQgw==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.7.1.tgz" }, { - "label": "OpenClaw Discord plugin 2026.6.10", - "packageSpec": "@openclaw/discord@2026.6.10", - "integrity": "sha512-NKp/j00l+rk5PC0Lv/0fOIiiQJ1c/OpG9471zqXUDKQie6pQ1Fi9KUZUouyoTMmfLh/n4S0CkEMqrON40eBKXA==", - "tarballUrl": "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz" + "label": "OpenClaw Discord plugin 2026.7.1", + "packageSpec": "@openclaw/discord@2026.7.1", + "integrity": "sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz" }, { - "label": "OpenClaw Slack plugin 2026.6.10", - "packageSpec": "@openclaw/slack@2026.6.10", - "integrity": "sha512-OOsMLjPcbWhQRM5XDwfdrACjJmKqavFtpuIlhHAXWrLrd/p7SyIVE9AoKS0yxOx6bqGDIMJ9+knzdViHMLgBdA==", - "tarballUrl": "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.6.10.tgz" + "label": "OpenClaw Slack plugin 2026.7.1", + "packageSpec": "@openclaw/slack@2026.7.1", + "integrity": "sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz" }, { - "label": "OpenClaw WhatsApp plugin 2026.6.10", - "packageSpec": "@openclaw/whatsapp@2026.6.10", - "integrity": "sha512-k/XrRdZY77SHrdaRwJOEB7/JRbjp4yVgGD/ZNyakjTMqo32XRVtwPBUnj7726rW8Kl5yyOMQQLKFiD9MDfhmPQ==", - "tarballUrl": "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.6.10.tgz" + "label": "OpenClaw WhatsApp plugin 2026.7.1", + "packageSpec": "@openclaw/whatsapp@2026.7.1", + "integrity": "sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz" }, { - "label": "OpenClaw Microsoft Teams plugin 2026.6.10", - "packageSpec": "@openclaw/msteams@2026.6.10", - "integrity": "sha512-GjHnCPvjbnI0C7mEFcdT2uKDH4/WwOe2dZBfQiWxBtkE76m6TNG0J9dJjD4mc8/pk8rXSO0cWw+KV9jzWtF9VA==", - "tarballUrl": "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz" + "label": "OpenClaw Microsoft Teams plugin 2026.7.1", + "packageSpec": "@openclaw/msteams@2026.7.1", + "integrity": "sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg==", + "tarballUrl": "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz" }, { "label": "Tencent WeChat plugin 2.4.3", diff --git a/ci/reviewed-npm-lifecycle-allowlist.json b/ci/reviewed-npm-lifecycle-allowlist.json index 323ad5e83b4..23db57f4a09 100644 --- a/ci/reviewed-npm-lifecycle-allowlist.json +++ b/ci/reviewed-npm-lifecycle-allowlist.json @@ -2,17 +2,17 @@ "schemaVersion": 1, "defaultPolicy": "deny", "reviewedArchivePackages": [ - "@openclaw/brave-plugin@2026.6.10", - "@openclaw/diagnostics-otel@2026.6.10", - "@openclaw/discord@2026.6.10", - "@openclaw/msteams@2026.6.10", - "@openclaw/slack@2026.6.10", - "@openclaw/whatsapp@2026.6.10", + "@openclaw/brave-plugin@2026.7.1", + "@openclaw/diagnostics-otel@2026.7.1", + "@openclaw/discord@2026.7.1", + "@openclaw/msteams@2026.7.1", + "@openclaw/slack@2026.7.1", + "@openclaw/whatsapp@2026.7.1", "@tencent-weixin/openclaw-weixin@2.4.3", "@zed-industries/codex-acp@0.11.1", "openclaw@2026.3.11", "openclaw@2026.4.24", - "openclaw@2026.6.10" + "openclaw@2026.7.1" ], "allowedLifecycleScripts": [ { @@ -22,7 +22,7 @@ "explicitCommand": "node /usr/local/lib/node_modules/openclaw/scripts/postinstall-bundled-plugins.mjs" }, { - "packageSpec": "openclaw@2026.6.10", + "packageSpec": "openclaw@2026.7.1", "event": "postinstall", "manifestCommand": "node scripts/postinstall-bundled-plugins.mjs", "explicitCommand": "node /usr/local/lib/node_modules/openclaw/scripts/postinstall-bundled-plugins.mjs" diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md new file mode 100644 index 00000000000..7e1134769a9 --- /dev/null +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -0,0 +1,143 @@ + + + +# OpenClaw 2026.7.1 dependency review + +Review date: 2026-07-20 + +## Decision + +Pin the production OpenClaw runtime and matching official plugins to the +non-prerelease `v2026.7.1` release. This replaces `2026.6.10`, whose bundled +graph contains the newly disclosed critical `tar` advisory. The reviewed +`openclaw@2026.7.1` graph contains `tar@7.5.19`; the audit report contains no +`tar` finding. + +The release lineage is unusually wide and divergent: the direct upstream +comparison reports 4,407 commits ahead and 34 behind. The maintainer requested +this exact stable release after reviewing that risk. The long-term source of +truth for these behaviors remains upstream OpenClaw, and this upgrade does not +turn NemoClaw's compiled-dist shims into supported upstream APIs. + +OpenClaw now requires Node `>=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0`. +NemoClaw therefore moves its exact `node:22-trixie-slim` digest to the image +whose amd64 config reports Node `22.23.1`. + +## Reviewed identities + +- `openclaw@2026.7.1` + - `sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g==` + - `https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz` +- `@openclaw/diagnostics-otel@2026.7.1` + - `sha512-XXhMifYWTgoR6yFN4T3JkHxdPvQCe8k1cNZjVIgXNmk1svCdBWuALfQQicmpemlmWwauIQuHYgBURY6k63e+rw==` +- `@openclaw/brave-plugin@2026.7.1` + - `sha512-7Z+GZ/6K6a8LlkTsWVnAZ1hv8EarORzHQvFHD7ekcg033FGJOXYPEZSbvvE3qR9vM+vnoZplNjMZ7vFMRcvQgw==` +- `@openclaw/discord@2026.7.1` + - `sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg==` +- `@openclaw/slack@2026.7.1` + - `sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA==` + - remediated archive: `sha512-ctU4iNWpx3IDPDXqjRdU4TvzhM/dXUvuDXJHcl82/gUTMOFHO8bW+2UTTTKTNAmZbPz/YBeztJb6oaJfxxusvw==` +- `@openclaw/whatsapp@2026.7.1` + - `sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ==` +- `@openclaw/msteams@2026.7.1` + - `sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg==` + - remediated archive: `sha512-qtdnGvSnxaOJPG5nY/qEhXQzZoJIqnzp+3jaq2DWVB74T+zBdb9i/KVsiGFloMSjXx/pg8+i+nkhKFTaEOYHZg==` +- `@zed-industries/codex-acp@0.11.1` + - `sha512-My2VSlBtvJipJhImHjFDej2ut/p00QqOISRnZgLgLrSIzjgvdcQvAhaZviWj7XPhk4UIdIb0OoA+Lrls824uiQ==` + - `https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.11.1.tgz` +- `@tencent-weixin/openclaw-weixin@2.4.3` + - `sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==` + +## Audit result and temporary Axios remediation + +The exact reviewed archive graph contains `822` total dependencies and reports +`1` moderate, `0` high, and `0` critical vulnerabilities. The critical `tar` +finding that blocked the previous pin is gone. The remaining moderate +`protobufjs` finding is below the configured `high` threshold. + +The published Slack and Microsoft Teams plugin archives bundle `axios@1.16.0`. +That version is in the affected range for the newly disclosed Axios +inherited-proxy advisory. NemoClaw therefore rebuilds only these two reviewed +plugin archives with this exact replacement graph: + +- `axios@1.18.0`, + `sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==`; +- `https-proxy-agent@5.0.1`, + `sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==`; +- `agent-base@6.0.2`, + `sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==`. + +`scripts/lib/openclaw-npm-remediation.mts` verifies the original plugin and +replacement package identities before it writes the archive. It rejects an +upstream graph that no longer resolves Axios `1.16.0`. It then verifies the +deterministic remediated archive integrity before installation. The production +plugin installer and `reviewed-npm-audit` use this same function. + +This remediation is limited to `@openclaw/slack@2026.7.1` and +`@openclaw/msteams@2026.7.1`. Remove it when a reviewed stable OpenClaw plugin +release bundles Axios `>=1.18.0` and passes the repository audit. + +The reviewed installer verifies each registry identity and downloaded tarball +integrity. `scripts/lib/reviewed-npm-archive.mts` uses `npm pack --json`, rejects +reported archive filenames containing unsafe archive paths, binds reviewed npm +installs to verified local archives, checks each reviewed npm plugin registry +integrity, and returns only the verified local `.tgz` path. + +## OpenClaw Compiled-Dist Patch Runtime Boundary + +`test/openclaw-real-patched-dist-harness.test.ts` materializes the exact public +archive under `NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS=1`, applies every current +NemoClaw patch, verifies syntax, and exercises the live device self-approval +proof. This is not a substitute for focused nightly E2E proof. + +The `2026.7.1` dist changed two reviewed shapes: + +- strict managed-proxy activation now uses `isStrictManagedProxyActive`; the + patch still activates only inside OpenShell and only without an explicit + dispatcher policy; +- queued follow-up execution now resolves inbound context before allocating a + run id; `scripts/patch-openclaw-chat-send.mts` preserves the submitted run id + at that new boundary; +- device-token authentication now rejects a requested scope upgrade before the + canonical pairing gate can create its pending request. The compatibility + patch continues only an exact CLI/operator request limited to + `operator.pairing`, `operator.read`, and `operator.write` into that gate; the + requested operation remains blocked until canonical pairing approval. + +`scripts/patch-openclaw-device-self-approval.mts` remains required. Its new +shape recognizers preserve the bounded stored-device credential flow and keep +the canonical `approveDevicePairing` transaction fail closed. + +## Existing security and runtime contracts + +The OpenClaw Diagnostics OTEL Host Gateway Boundary remains unchanged. The +`openclaw-diagnostics-otel-local` policy is limited to the diagnostics plugin, +which imports `OTLPTraceExporter` and contains no `web_fetch`, `fetchWithSsrFGuard` +call path. + +Messaging contracts remain pinned to the reviewed runtime shapes: + +- `dist/pipeline.runtime-*.js`, which exports `prepareSlackMessage`; +- the preload imports the hashed pipeline runtime for `prepareSlackMessage` and + only reports `openclaw-pipeline-runtime` after allowed prepare; +- `dist/extensions/telegram/runtime-api.js`, which exports `sendMessageTelegram`; +- runtime validation fails closed if the installed runtime file is missing; +- tests reject claiming `openclaw-pipeline-runtime` inbound proof when a fixture + imports `dist/extensions/telegram/test-api.js`. + +Legacy upgrade fixtures remain gated behind +`NEMOCLAW_E2E_FIXTURE_LEGACY_OPENCLAW=1`. The +`scripts/check-production-build-args.sh` guard rejects those fixture-only +production build args. + +## Issue #4434 full live acceptance + +`scripts/patch-openclaw-issue-4434-diagnostics.mts` and +`test/issue-4434-error-fields.test.ts` remain tied to the gateway/upstream +reporting layer. The #4434 compatibility-shim disposition is explicitly accepted +for this release. 3/3 fields are present in the NemoClaw-patched runtime output, +while 3/3 fields are missing in the upstream-shaped `openclaw@2026.7.1` output. + +The live acceptance requires the recovery text: +`Recovery hint: check sandbox egress and provider reachability, then retry.` +The focused live guard retains its default 180-second timeout. diff --git a/nemoclaw-blueprint/policies/presets/weather.yaml b/nemoclaw-blueprint/policies/presets/weather.yaml index 4891d24afb0..63aaa8f5b98 100644 --- a/nemoclaw-blueprint/policies/presets/weather.yaml +++ b/nemoclaw-blueprint/policies/presets/weather.yaml @@ -10,7 +10,7 @@ network_policies: name: weather endpoints: # Host the bundled OpenClaw weather skill calls with curl on the pinned - # OpenClaw version declared by agents/openclaw/manifest.yaml (2026.6.10). + # OpenClaw version declared by agents/openclaw/manifest.yaml (2026.7.1). # Reviewed package/skills/weather/SKILL.md SHA-256: # 62ab4821aa873949d1c1091836be1659a42b32caadce4bd145f5505a1ceaeec1. # Revalidate this host whenever that version changes; replace this prose diff --git a/nemoclaw/package.json b/nemoclaw/package.json index f8b0fdbcb50..ebc8a5987d5 100644 --- a/nemoclaw/package.json +++ b/nemoclaw/package.json @@ -15,7 +15,7 @@ "minGatewayVersion": "2026.5.22" }, "build": { - "openclawVersion": "2026.6.10" + "openclawVersion": "2026.7.1" } }, "scripts": { diff --git a/scripts/audit-reviewed-npm-graph.mts b/scripts/audit-reviewed-npm-graph.mts index 83d43f7a3ce..43dadc0f452 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -7,6 +7,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { remediateReviewedOpenClawPluginArchive } from "./lib/openclaw-npm-remediation.mts"; import { packReviewedNpmArchive, verifyReviewedNpmMetadata } from "./lib/reviewed-npm-archive.mts"; type Severity = "info" | "low" | "moderate" | "high" | "critical"; @@ -135,15 +136,20 @@ function materializeArchiveGraph(packages: readonly ReviewedPackage[], tempRoot: path.join(graphDirectory, "package.json"), `${JSON.stringify({ name: "nemoclaw-reviewed-production-graph", private: true, version: "1.0.0" }, null, 2)}\n`, ); - const archives = packages.map((reviewed) => - packReviewedNpmArchive({ + const archives = packages.map((reviewed) => { + const archive = packReviewedNpmArchive({ expectedIntegrity: reviewed.integrity, label: reviewed.label, packageSpec: reviewed.packageSpec, tarballUrl: reviewed.tarballUrl, tempDirectory: tempRoot, - }), - ); + }); + return remediateReviewedOpenClawPluginArchive({ + archivePath: archive.archivePath, + packageSpec: reviewed.packageSpec, + workingDirectory: archive.rootDirectory, + }); + }); run( "npm", [ diff --git a/scripts/check-messaging-plan-image-boundary.mts b/scripts/check-messaging-plan-image-boundary.mts index fadb5d9a385..545725051a6 100644 --- a/scripts/check-messaging-plan-image-boundary.mts +++ b/scripts/check-messaging-plan-image-boundary.mts @@ -26,7 +26,7 @@ export const TEAMS_APP_ID = "nemoclaw-ci-teams-app-id"; export const TEAMS_TENANT_ID = "00000000-0000-0000-0000-000000000042"; export const TEAMS_SECRET_PLACEHOLDER = "openshell:resolve:env:MSTEAMS_APP_PASSWORD"; export const OPENCLAW_TEAMS_PACKAGE_SPEC = "npm:@openclaw/msteams@{{openclaw.version}}"; -export const OPENCLAW_TEAMS_PACKAGE_VERSION = "2026.6.10"; +export const OPENCLAW_TEAMS_PACKAGE_VERSION = "2026.7.1"; export const HERMES_TEAMS_PACKAGE_SPEC = "microsoft-teams-apps==2.0.13.4"; export const HERMES_AIOHTTP_PACKAGE_SPEC = "aiohttp==3.14.1"; diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts new file mode 100755 index 00000000000..b4faccfa324 --- /dev/null +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -0,0 +1,451 @@ +#!/usr/bin/env -S node --experimental-strip-types +// 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 { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { packReviewedNpmArchive } from "./reviewed-npm-archive.mts"; + +type JsonObject = Record; + +type Remediation = Readonly<{ + expectedPatchedIntegrity: string; +}>; + +type RemediationRequest = Readonly<{ + archivePath: string; + env?: NodeJS.ProcessEnv; + packageSpec: string; + workingDirectory: string; +}>; + +type BuildRequest = RemediationRequest & + Readonly<{ + expectedPatchedIntegrity?: string; + }>; + +export type RemediatedArchive = Readonly<{ + archivePath: string; + integrity: string; + remediated: boolean; +}>; + +const AXIOS_VERSION = "1.18.0"; +const AXIOS_INTEGRITY = + "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw=="; +const AXIOS_TARBALL = "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz"; +const HTTPS_PROXY_AGENT_VERSION = "5.0.1"; +const HTTPS_PROXY_AGENT_INTEGRITY = + "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="; +const HTTPS_PROXY_AGENT_TARBALL = + "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz"; +const AGENT_BASE_VERSION = "6.0.2"; +const AGENT_BASE_INTEGRITY = + "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="; +const AGENT_BASE_TARBALL = "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz"; + +const REMEDIATIONS: Readonly> = Object.freeze({ + "@openclaw/msteams@2026.7.1": { + expectedPatchedIntegrity: + "sha512-qtdnGvSnxaOJPG5nY/qEhXQzZoJIqnzp+3jaq2DWVB74T+zBdb9i/KVsiGFloMSjXx/pg8+i+nkhKFTaEOYHZg==", + }, + "@openclaw/slack@2026.7.1": { + expectedPatchedIntegrity: + "sha512-ctU4iNWpx3IDPDXqjRdU4TvzhM/dXUvuDXJHcl82/gUTMOFHO8bW+2UTTTKTNAmZbPz/YBeztJb6oaJfxxusvw==", + }, +}); + +function run(command: string, args: readonly string[], cwd: string, env: NodeJS.ProcessEnv) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf-8", + env, + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + } + return result.stdout; +} + +function validateArchiveMembers(archivePath: string, cwd: string, env: NodeJS.ProcessEnv): void { + const names = run("tar", ["-tzf", archivePath], cwd, env) + .split("\n") + .filter((entry) => entry.length > 0); + const verbose = run("tar", ["-tvzf", archivePath], cwd, env) + .split("\n") + .filter((entry) => entry.length > 0); + if (names.length === 0 || verbose.length !== names.length) { + throw new Error(`npm archive ${archivePath} has an invalid member listing`); + } + const seen = new Set(); + for (let index = 0; index < names.length; index += 1) { + const member = names[index] as string; + const type = (verbose[index] as string)[0]; + const normalized = member.endsWith("/") ? member.slice(0, -1) : member; + if ( + (type !== "-" && type !== "d") || + (normalized !== "package" && !normalized.startsWith("package/")) || + normalized.includes("\\") || + normalized.split("/").some((part) => part === "" || part === "." || part === "..") || + seen.has(normalized) + ) { + throw new Error(`npm archive ${archivePath} has an unsafe member: ${member}`); + } + seen.add(normalized); + } + if (!seen.has("package/package.json")) { + throw new Error(`npm archive ${archivePath} has no package/package.json`); + } +} + +function extractArchive( + archivePath: string, + destination: string, + cwd: string, + env: NodeJS.ProcessEnv, +): string { + validateArchiveMembers(archivePath, cwd, env); + mkdirSync(destination, { recursive: true, mode: 0o700 }); + run("tar", ["-xzf", archivePath, "-C", destination], cwd, env); + const packageDirectory = join(destination, "package"); + if (!existsSync(join(packageDirectory, "package.json"))) { + throw new Error(`npm archive ${archivePath} did not extract a package directory`); + } + return packageDirectory; +} + +function readJson(path: string): JsonObject { + const parsed = JSON.parse(readFileSync(path, "utf-8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${path} must contain a JSON object`); + } + return parsed as JsonObject; +} + +function writeJson(path: string, value: JsonObject): void { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} + +function sortedObject(value: JsonObject): JsonObject { + return Object.fromEntries( + Object.entries(value).sort(([left], [right]) => left.localeCompare(right)), + ); +} + +function requirePackageIdentity( + packageJson: JsonObject, + expectedName: string, + expectedVersion: string, + label: string, +): void { + if (packageJson.name !== expectedName || packageJson.version !== expectedVersion) { + throw new Error( + `${label} must be ${expectedName}@${expectedVersion}; found ${String(packageJson.name)}@${String(packageJson.version)}`, + ); + } +} + +function requireDependencyShape( + packageJson: JsonObject, + expected: JsonObject, + label: string, +): void { + if ( + !packageJson.dependencies || + JSON.stringify(sortedObject(packageJson.dependencies)) !== + JSON.stringify(sortedObject(expected)) + ) { + throw new Error(`${label} dependency graph changed; review the remediation before updating it`); + } +} + +export function patchOpenClawPluginPackageGraph( + packageDirectory: string, + packageSpec: string, +): void { + const packageJsonPath = join(packageDirectory, "package.json"); + const shrinkwrapPath = join(packageDirectory, "npm-shrinkwrap.json"); + const packageJson = readJson(packageJsonPath); + const versionAt = packageSpec.lastIndexOf("@"); + const expectedName = packageSpec.slice(0, versionAt); + const expectedVersion = packageSpec.slice(versionAt + 1); + requirePackageIdentity(packageJson, expectedName, expectedVersion, "OpenClaw plugin"); + if (packageJson.dependencies?.axios !== undefined) { + throw new Error(`${packageSpec} already declares axios; review the remediation boundary`); + } + if (!Array.isArray(packageJson.bundledDependencies)) { + throw new Error(`${packageSpec} has no bundledDependencies array`); + } + if (packageJson.bundledDependencies.includes("axios")) { + throw new Error(`${packageSpec} already bundles axios; review the remediation boundary`); + } + packageJson.dependencies = sortedObject({ ...packageJson.dependencies, axios: AXIOS_VERSION }); + packageJson.bundledDependencies = [...packageJson.bundledDependencies, "axios"]; + + const shrinkwrap = readJson(shrinkwrapPath); + if (shrinkwrap.lockfileVersion !== 3 || !shrinkwrap.packages?.[""]) { + throw new Error(`${packageSpec} must ship an npm lockfileVersion 3 shrinkwrap`); + } + const root = shrinkwrap.packages[""] as JsonObject; + if (root.dependencies?.axios !== undefined) { + throw new Error(`${packageSpec} shrinkwrap already declares axios at the root`); + } + root.dependencies = sortedObject({ ...root.dependencies, axios: AXIOS_VERSION }); + root.bundleDependencies = [...packageJson.bundledDependencies]; + + const axiosKey = "node_modules/axios"; + const axios = shrinkwrap.packages[axiosKey] as JsonObject | undefined; + if (axios?.version !== "1.16.0") { + throw new Error(`${packageSpec} must resolve ${axiosKey} to 1.16.0 before remediation`); + } + shrinkwrap.packages[axiosKey] = { + version: AXIOS_VERSION, + resolved: AXIOS_TARBALL, + integrity: AXIOS_INTEGRITY, + license: "MIT", + dependencies: { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0", + }, + }; + + const httpsProxyAgentKey = "node_modules/axios/node_modules/https-proxy-agent"; + const agentBaseKey = `${httpsProxyAgentKey}/node_modules/agent-base`; + if (shrinkwrap.packages[httpsProxyAgentKey] || shrinkwrap.packages[agentBaseKey]) { + throw new Error(`${packageSpec} already has the nested Axios proxy dependency remediation`); + } + shrinkwrap.packages[httpsProxyAgentKey] = { + version: HTTPS_PROXY_AGENT_VERSION, + resolved: HTTPS_PROXY_AGENT_TARBALL, + integrity: HTTPS_PROXY_AGENT_INTEGRITY, + license: "MIT", + dependencies: { "agent-base": "6", debug: "4" }, + engines: { node: ">= 6" }, + }; + shrinkwrap.packages[agentBaseKey] = { + version: AGENT_BASE_VERSION, + resolved: AGENT_BASE_TARBALL, + integrity: AGENT_BASE_INTEGRITY, + license: "MIT", + dependencies: { debug: "4" }, + engines: { node: ">= 6.0.0" }, + }; + + writeJson(packageJsonPath, packageJson); + writeJson(shrinkwrapPath, shrinkwrap); +} + +function copyReplacementPackage(source: string, destination: string): void { + rmSync(destination, { recursive: true, force: true }); + mkdirSync(resolve(destination, ".."), { recursive: true, mode: 0o755 }); + cpSync(source, destination, { recursive: true, force: true }); +} + +function packReplacement( + packageSpec: string, + expectedIntegrity: string, + tarballUrl: string, + workingDirectory: string, + env: NodeJS.ProcessEnv, +) { + return packReviewedNpmArchive({ + env, + expectedIntegrity, + label: `OpenClaw npm remediation dependency ${packageSpec}`, + packageSpec, + tarballUrl, + tempDirectory: workingDirectory, + }); +} + +export function buildRemediatedOpenClawPluginArchive(request: BuildRequest): RemediatedArchive { + if (!REMEDIATIONS[request.packageSpec]) { + throw new Error(`No OpenClaw npm remediation is defined for ${request.packageSpec}`); + } + const env = { + ...process.env, + ...request.env, + NPM_CONFIG_AUDIT: "false", + NPM_CONFIG_FUND: "false", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + NPM_CONFIG_UPDATE_NOTIFIER: "false", + npm_config_ignore_scripts: "true", + }; + const workingDirectory = resolve(request.workingDirectory); + mkdirSync(workingDirectory, { recursive: true, mode: 0o700 }); + const remediationRoot = mkdtempSync(join(workingDirectory, "openclaw-npm-remediation-")); + const sourcePackage = extractArchive( + resolve(request.archivePath), + join(remediationRoot, "source"), + remediationRoot, + env, + ); + const axiosArchive = packReplacement( + `axios@${AXIOS_VERSION}`, + AXIOS_INTEGRITY, + AXIOS_TARBALL, + remediationRoot, + env, + ); + const httpsProxyAgentArchive = packReplacement( + `https-proxy-agent@${HTTPS_PROXY_AGENT_VERSION}`, + HTTPS_PROXY_AGENT_INTEGRITY, + HTTPS_PROXY_AGENT_TARBALL, + remediationRoot, + env, + ); + const agentBaseArchive = packReplacement( + `agent-base@${AGENT_BASE_VERSION}`, + AGENT_BASE_INTEGRITY, + AGENT_BASE_TARBALL, + remediationRoot, + env, + ); + const axiosPackage = extractArchive( + axiosArchive.archivePath, + join(remediationRoot, "axios"), + remediationRoot, + env, + ); + const httpsProxyAgentPackage = extractArchive( + httpsProxyAgentArchive.archivePath, + join(remediationRoot, "https-proxy-agent"), + remediationRoot, + env, + ); + const agentBasePackage = extractArchive( + agentBaseArchive.archivePath, + join(remediationRoot, "agent-base"), + remediationRoot, + env, + ); + const axiosPackageJson = readJson(join(axiosPackage, "package.json")); + const httpsProxyAgentPackageJson = readJson(join(httpsProxyAgentPackage, "package.json")); + const agentBasePackageJson = readJson(join(agentBasePackage, "package.json")); + requirePackageIdentity(axiosPackageJson, "axios", AXIOS_VERSION, "Axios remediation package"); + requirePackageIdentity( + httpsProxyAgentPackageJson, + "https-proxy-agent", + HTTPS_PROXY_AGENT_VERSION, + "Axios proxy remediation package", + ); + requirePackageIdentity( + agentBasePackageJson, + "agent-base", + AGENT_BASE_VERSION, + "Axios agent-base remediation package", + ); + requireDependencyShape( + axiosPackageJson, + { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0", + }, + "axios@1.18.0", + ); + requireDependencyShape( + httpsProxyAgentPackageJson, + { "agent-base": "6", debug: "4" }, + "https-proxy-agent@5.0.1", + ); + requireDependencyShape(agentBasePackageJson, { debug: "4" }, "agent-base@6.0.2"); + + const axiosTarget = join(sourcePackage, "node_modules", "axios"); + copyReplacementPackage(axiosPackage, axiosTarget); + copyReplacementPackage( + httpsProxyAgentPackage, + join(axiosTarget, "node_modules", "https-proxy-agent"), + ); + copyReplacementPackage( + agentBasePackage, + join(axiosTarget, "node_modules", "https-proxy-agent", "node_modules", "agent-base"), + ); + patchOpenClawPluginPackageGraph(sourcePackage, request.packageSpec); + + const outputDirectory = join(remediationRoot, "output"); + mkdirSync(outputDirectory, { recursive: true, mode: 0o700 }); + const packedJson = run( + "npm", + ["pack", ".", "--pack-destination", outputDirectory, "--ignore-scripts", "--json"], + sourcePackage, + env, + ); + const packed = JSON.parse(packedJson); + if (!Array.isArray(packed) || packed.length !== 1 || typeof packed[0]?.filename !== "string") { + throw new Error(`npm pack returned an invalid remediation result for ${request.packageSpec}`); + } + const archivePath = resolve(outputDirectory, basename(packed[0].filename)); + validateArchiveMembers(archivePath, remediationRoot, env); + const integrity = `sha512-${createHash("sha512").update(readFileSync(archivePath)).digest("base64")}`; + if (request.expectedPatchedIntegrity && integrity !== request.expectedPatchedIntegrity) { + throw new Error( + `Remediated ${request.packageSpec} integrity mismatch: expected ${request.expectedPatchedIntegrity}, got ${integrity}`, + ); + } + return { archivePath, integrity, remediated: true }; +} + +export function remediateReviewedOpenClawPluginArchive( + request: RemediationRequest, +): RemediatedArchive { + const remediation = REMEDIATIONS[request.packageSpec]; + if (!remediation) { + return { + archivePath: resolve(request.archivePath), + integrity: `sha512-${createHash("sha512") + .update(readFileSync(resolve(request.archivePath))) + .digest("base64")}`, + remediated: false, + }; + } + return buildRemediatedOpenClawPluginArchive({ + ...request, + expectedPatchedIntegrity: remediation.expectedPatchedIntegrity, + }); +} + +function isMainModule(): boolean { + return process.argv[1] ? import.meta.url === pathToFileURL(resolve(process.argv[1])).href : false; +} + +if (isMainModule()) { + const args = process.argv.slice(2); + const value = (name: string): string => { + const index = args.indexOf(name); + const result = index >= 0 ? args[index + 1] : undefined; + if (!result) throw new Error(`Missing ${name}`); + return result; + }; + try { + console.log( + JSON.stringify( + buildRemediatedOpenClawPluginArchive({ + archivePath: value("--archive"), + packageSpec: value("--package-spec"), + workingDirectory: value("--working-directory"), + }), + ), + ); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 054487b1cb6..da11047fafc 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2851,7 +2851,7 @@ def brief_child_error(out, err): # runtime environment, so list calls resolve the same live gateway through # local loopback instead of the injected private-interface URL. Approval calls # additionally drop the gateway env triplet so OpenClaw must use the local -# device token. The reviewed 2026.6.10 dist patch requests only +# device token. The reviewed 2026.7.1 dist patch requests only # operator.pairing for a complete bounded CLI self-upgrade and forces the # existing local-only stored-device-auth path so a shared token reloaded from # config cannot win authentication. The gateway then validates and commits in @@ -3452,7 +3452,7 @@ openclaw() { local _nemoclaw_guard_request_handled=0 _nemoclaw_guard_request_status=0 # NemoClaw#4462: approval calls temporarily drop the gateway URL/port/token # so OpenClaw resolves the local loopback gateway and device token. The - # reviewed 2026.6.10 compatibility patch then performs bounded same-device + # reviewed 2026.7.1 compatibility patch then performs bounded same-device # scope upgrades in the gateway's canonical locked pairing writer. This # wrapper never reads or writes pending.json/paired.json. if [ "${1:-}" = "devices" ] && [ "${2:-}" = "approve" ]; then diff --git a/scripts/patch-openclaw-chat-send.mts b/scripts/patch-openclaw-chat-send.mts index 9acdc42facd..5a525fe7f3b 100755 --- a/scripts/patch-openclaw-chat-send.mts +++ b/scripts/patch-openclaw-chat-send.mts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 /* - * Temporary NemoClaw compatibility shim for OpenClaw 2026.5.x and 2026.6.x + * Temporary NemoClaw compatibility shim for OpenClaw 2026.5.x through 2026.7.x * chat.send gateway behavior. Remove this when upstream OpenClaw preserves * submitted chat.send run lineage and stops emitting empty terminal chat * events. @@ -229,7 +229,8 @@ function patchFollowupRunIdPreservation(source: string, file: string): PatchResu // 2026.5.22 closes over params.opts and uses createReplyOperation, and // 2026.5.27 closes over params.opts and admits a queued reply turn before // creating the run id. OpenClaw 2026.6.10 keeps that admission flow but routes - // the session id through effectiveQueued and includes routeThreadId. + // the session id through effectiveQueued and includes routeThreadId. OpenClaw + // 2026.7.1 resolves the queued inbound context immediately before the run id. let nextSource = working.replace( /(replyOperation = createReplyOperation\(\{\n\s*sessionId: run\.sessionId,\n\s*sessionKey: replySessionKey \?\? "",\n\s*resetTriggered: false,\n\s*upstreamAbortSignal: queued\.abortSignal(?: \?\? opts\?\.abortSignal)?\n\s*\}\);\n\s*)const runId = crypto\.randomUUID\(\);/, (_match, prefix) => @@ -244,6 +245,14 @@ function patchFollowupRunIdPreservation(source: string, file: string): PatchResu `// nemoclaw: preserve chat.send run ids in followup queue (#2603, #3145)`, ); } + if (nextSource === working) { + nextSource = working.replace( + /(const currentInboundContext = opts\?\.isHeartbeat === true \? effectiveQueued\.currentInboundContext : refreshActiveGoalContext\(effectiveQueued\.currentInboundContext, goalContextSessionEntry\);\n\s*)const runId = crypto\.randomUUID\(\);/, + (_match, prefix) => + `${prefix}const runId = queued.runId ?? opts?.runId ?? crypto.randomUUID(); ` + + `// nemoclaw: preserve chat.send run ids in followup queue (#2603, #3145)`, + ); + } if (nextSource === working) { return { nextSource: source, diff --git a/scripts/patch-openclaw-device-self-approval.mts b/scripts/patch-openclaw-device-self-approval.mts index ba9e10d316c..690ea654ba1 100644 --- a/scripts/patch-openclaw-device-self-approval.mts +++ b/scripts/patch-openclaw-device-self-approval.mts @@ -2,17 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 /* - * Temporary compatibility patch for OpenClaw 2026.6.10 device scope upgrades. + * Temporary compatibility patch for OpenClaw 2026.6.10 through 2026.7.1 device + * scope upgrades. * - * The 2026.6.10 devices CLI asks for the scopes it is trying to approve. A - * device that currently has only operator.pairing is therefore rejected by - * the gateway handshake before device.pair.approve can run. Its operator.admin - * retry fails the same way, after which NemoClaw historically repaired the two - * JSON state files directly. A configured gateway.auth.token would otherwise + * The devices CLI asks for the scopes it is trying to approve. A device that + * currently has only operator.pairing is therefore rejected by the gateway + * handshake before device.pair.approve can run. OpenClaw 2026.7.1 also rejects + * that valid device token during authentication, before its canonical pairing + * path can create the scope-upgrade request. Its operator.admin retry fails the + * same way, after which NemoClaw historically repaired the two JSON state files + * directly. A configured gateway.auth.token would otherwise * take precedence over the already-issued device credential and reach the * handler as shared-token auth. Keep the entire approval in OpenClaw instead: - * for the exact same-device CLI repair, explicitly use OpenClaw's stored device - * credential with operator.pairing, then let the gateway's canonical + * for the exact bounded CLI mismatch, continue only into OpenClaw's pairing + * gate; for the resulting same-device repair, explicitly use OpenClaw's stored + * device credential with operator.pairing, then let the gateway's canonical * approveDevicePairing path reload, lock, rotate the token, persist, broadcast, * and respond. * @@ -42,6 +46,8 @@ const CLI_APPLIED_MARKERS = [ CLI_RETRY_MARKER, CLI_LIST_MARKER, ] as const; +const AUTH_SCOPE_UPGRADE_MARKER = + "nemoclaw: route bounded CLI device-token scope upgrade into pairing"; const HANDLER_MARKER = "nemoclaw: bounded same-device scope approval"; const STATE_MARKER = "nemoclaw: validate bounded self-approval inside pairing lock"; const STATE_TRANSACTION_MARKER = "nemoclaw: recover bounded self-approval state transaction"; @@ -294,10 +300,50 @@ const CLI_APPROVE_CALL_REPLACEMENT = [ ].join("\n"); const CLI_ADMIN_RETRY_TARGET = '\t\tif (isDevicePairingApprovalDenied(error) && !scopes?.includes("operator.admin")) return await callGatewayCli("device.pair.approve", opts, { requestId }, { scopes: [ADMIN_SCOPE] });'; +const CLI_ADMIN_RETRY_TARGET_2026_7_1 = + '\t\tif (isDevicePairingApprovalDenied(error) && !scopes?.includes("operator.admin")) try {'; const CLI_ADMIN_RETRY_REPLACEMENT = [ "\t\tif (nemoclawUseStoredDeviceAuth) throw error; // nemoclaw: keep bounded stored device auth fail closed (#4462)", CLI_ADMIN_RETRY_TARGET, ].join("\n"); +const CLI_ADMIN_RETRY_REPLACEMENT_2026_7_1 = [ + "\t\tif (nemoclawUseStoredDeviceAuth) throw error; // nemoclaw: keep bounded stored device auth fail closed (#4462)", + CLI_ADMIN_RETRY_TARGET_2026_7_1, +].join("\n"); + +const AUTH_DECISION_CALL_TARGET = [ + "\t\t\t\t\trole,", + "\t\t\t\t\tscopes,", + "\t\t\t\t\trateLimiter: authRateLimiter,", +].join("\n"); +const AUTH_DECISION_CALL_REPLACEMENT = [ + "\t\t\t\t\trole,", + "\t\t\t\t\tscopes,", + "\t\t\t\t\tclientId: connectParams.client.id,", + "\t\t\t\t\tclientMode: connectParams.client.mode,", + "\t\t\t\t\trateLimiter: authRateLimiter,", +].join("\n"); +const AUTH_DEVICE_TOKEN_TARGET = [ + "\t\tif (tokenCheck.ok) {", + "\t\t\tauthOk = true;", + '\t\t\tauthMethod = "device-token";', +].join("\n"); +const AUTH_DEVICE_TOKEN_REPLACEMENT = [ + '\t\tconst nemoclawAllowedUpgradeScopes = new Set(["operator.pairing", "operator.read", "operator.write"]);', + '\t\tconst nemoclawScopeUpgradeScopes = Array.isArray(params.scopes) ? params.scopes.map((scope) => typeof scope === "string" ? scope.trim() : "") : [];', + "\t\tconst nemoclawCliScopeUpgrade =", + "\t\t\t!tokenCheck.ok &&", + '\t\t\t(tokenCheck.reason === "scope-mismatch" || tokenCheck.reason === "scope_mismatch") &&', + "\t\t\tparams.clientId === GATEWAY_CLIENT_IDS.CLI &&", + "\t\t\tparams.clientMode === GATEWAY_CLIENT_MODES.CLI &&", + '\t\t\tparams.role === "operator" &&', + "\t\t\tnemoclawScopeUpgradeScopes.length > 0 &&", + "\t\t\tnemoclawScopeUpgradeScopes.length === new Set(nemoclawScopeUpgradeScopes).size &&", + "\t\t\tnemoclawScopeUpgradeScopes.every((scope) => scope && nemoclawAllowedUpgradeScopes.has(scope));", + "\t\tif (tokenCheck.ok || nemoclawCliScopeUpgrade) { // nemoclaw: route bounded CLI device-token scope upgrade into pairing (#4462)", + "\t\t\tauthOk = true;", + '\t\t\tauthMethod = "device-token";', +].join("\n"); const HANDLER_HELPER = [ "function resolveNemoClawSelfApprovalIdentity(pending, authz, client) {", @@ -618,6 +664,26 @@ const STATE_LIST_REPLACEMENT = [ "\t});", "}", ].join("\n"); +const STATE_LIST_TARGET_2026_7_1 = [ + "async function listDevicePairing(baseDir) {", + "\tconst state = await loadState(baseDir);", + "\treturn {", + "\t\tpending: Object.values(state.pendingById).map(toPublicPendingDevicePairingRequest).toSorted((a, b) => b.ts - a.ts),", + "\t\tpaired: Object.values(state.pairedByDeviceId).toSorted((a, b) => b.approvedAtMs - a.approvedAtMs)", + "\t};", + "}", +].join("\n"); +const STATE_LIST_REPLACEMENT_2026_7_1 = [ + "async function listDevicePairing(baseDir) {", + "\treturn await withLock(async () => {", + "\t\tconst state = await loadState(baseDir);", + "\t\treturn {", + "\t\t\tpending: Object.values(state.pendingById).map(toPublicPendingDevicePairingRequest).toSorted((a, b) => b.ts - a.ts),", + "\t\t\tpaired: Object.values(state.pairedByDeviceId).toSorted((a, b) => b.approvedAtMs - a.approvedAtMs)", + "\t\t};", + "\t});", + "}", +].join("\n"); const STATE_GET_PAIRED_TARGET = [ "/** Return one paired device by normalized device id. */", "async function getPairedDevice(deviceId, baseDir) {", @@ -642,6 +708,22 @@ const STATE_GET_PENDING_REPLACEMENT = [ "\treturn await withLock(async () => (await loadState(baseDir)).pendingById[requestId] ?? null);", "}", ].join("\n"); +const STATE_GET_PENDING_TARGET_2026_7_1 = [ + "/** Return one pending pairing request by request id. */", + "async function getPendingDevicePairing(requestId, baseDir) {", + "\tconst pending = (await loadState(baseDir)).pendingById[requestId];", + "\treturn pending ? toPublicPendingDevicePairingRequest(pending) : null;", + "}", +].join("\n"); +const STATE_GET_PENDING_REPLACEMENT_2026_7_1 = [ + "/** Return one pending pairing request by request id. */", + "async function getPendingDevicePairing(requestId, baseDir) {", + "\treturn await withLock(async () => {", + "\t\tconst pending = (await loadState(baseDir)).pendingById[requestId];", + "\t\treturn pending ? toPublicPendingDevicePairingRequest(pending) : null;", + "\t});", + "}", +].join("\n"); const STATE_FUNCTION_ANCHOR = "async function approveDevicePairing(requestId, optionsOrBaseDir, maybeBaseDir) {"; const STATE_LOCKED_TARGET = [ @@ -807,10 +889,17 @@ const FILE_SPECS: FileSpec[] = [ file, ); if (result.error) return { source, status: "no-match", error: result.error }; + const retryTarget = result.source.includes(CLI_ADMIN_RETRY_TARGET) + ? CLI_ADMIN_RETRY_TARGET + : CLI_ADMIN_RETRY_TARGET_2026_7_1; + const retryReplacement = + retryTarget === CLI_ADMIN_RETRY_TARGET + ? CLI_ADMIN_RETRY_REPLACEMENT + : CLI_ADMIN_RETRY_REPLACEMENT_2026_7_1; result = replaceExactlyOnce( result.source, - CLI_ADMIN_RETRY_TARGET, - CLI_ADMIN_RETRY_REPLACEMENT, + retryTarget, + retryReplacement, "devices CLI stored-auth fail-closed retry target", file, ); @@ -819,6 +908,42 @@ const FILE_SPECS: FileSpec[] = [ : { source: result.source, status: "would-apply" }; }, }, + { + id: "gateway-auth-scope-upgrade", + label: "device-token scope-upgrade gateway auth runtime", + marker: AUTH_SCOPE_UPGRADE_MARKER, + selector(source) { + return ( + source.includes("async function resolveConnectAuthDecisionCore(params)") && + source.includes("const authDecision = await resolveConnectAuthDecision({") && + source.includes("verifyDeviceToken: async") && + (source.includes(AUTH_DEVICE_TOKEN_TARGET) || source.includes(AUTH_SCOPE_UPGRADE_MARKER)) + ); + }, + patch(source, file) { + if (source.includes(AUTH_SCOPE_UPGRADE_MARKER)) { + return { source, status: "already-applied" }; + } + let result = replaceExactlyOnce( + source, + AUTH_DECISION_CALL_TARGET, + AUTH_DECISION_CALL_REPLACEMENT, + "gateway auth decision CLI identity target", + file, + ); + if (result.error) return { source, status: "no-match", error: result.error }; + result = replaceExactlyOnce( + result.source, + AUTH_DEVICE_TOKEN_TARGET, + AUTH_DEVICE_TOKEN_REPLACEMENT, + "gateway device-token scope-upgrade target", + file, + ); + return result.error + ? { source, status: "no-match", error: result.error } + : { source: result.source, status: "would-apply" }; + }, + }, { id: "gateway-handler", label: "device pairing gateway handler", @@ -902,10 +1027,13 @@ const FILE_SPECS: FileSpec[] = [ file, ); if (result.error) return { source, status: "no-match", error: result.error }; + const listTarget = result.source.includes(STATE_LIST_TARGET) + ? STATE_LIST_TARGET + : STATE_LIST_TARGET_2026_7_1; result = replaceExactlyOnce( result.source, - STATE_LIST_TARGET, - STATE_LIST_REPLACEMENT, + listTarget, + listTarget === STATE_LIST_TARGET ? STATE_LIST_REPLACEMENT : STATE_LIST_REPLACEMENT_2026_7_1, "canonical pairing list lock target", file, ); @@ -918,10 +1046,15 @@ const FILE_SPECS: FileSpec[] = [ file, ); if (result.error) return { source, status: "no-match", error: result.error }; + const pendingTarget = result.source.includes(STATE_GET_PENDING_TARGET) + ? STATE_GET_PENDING_TARGET + : STATE_GET_PENDING_TARGET_2026_7_1; result = replaceExactlyOnce( result.source, - STATE_GET_PENDING_TARGET, - STATE_GET_PENDING_REPLACEMENT, + pendingTarget, + pendingTarget === STATE_GET_PENDING_TARGET + ? STATE_GET_PENDING_REPLACEMENT + : STATE_GET_PENDING_REPLACEMENT_2026_7_1, "canonical pending-device reader lock target", file, ); diff --git a/scripts/patch-openclaw-issue-4434-diagnostics.mts b/scripts/patch-openclaw-issue-4434-diagnostics.mts index e6b18ee82df..6b49acdd2ab 100755 --- a/scripts/patch-openclaw-issue-4434-diagnostics.mts +++ b/scripts/patch-openclaw-issue-4434-diagnostics.mts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 /* - * Temporary NemoClaw compatibility shim for OpenClaw 2026.6.10 TUI error output. + * Temporary NemoClaw compatibility shim for OpenClaw 2026.7.1 TUI error output. * Remove this when upstream OpenClaw reports structured unreachable-inference * diagnostics for sandbox fetch failures and inference timeouts. */ diff --git a/scripts/validate-openclaw-tool-search.mts b/scripts/validate-openclaw-tool-search.mts index a15fbbb1144..f886f8cf525 100755 --- a/scripts/validate-openclaw-tool-search.mts +++ b/scripts/validate-openclaw-tool-search.mts @@ -15,6 +15,7 @@ const RUNTIME_FUNCTION_NAMES = [ const RUNTIME_MODULE_FILE_PATTERNS = new Map([ ["2026.5.27", /^pi-tools-.*\.js$/], ["2026.6.10", /^agent-tools-.*\.js$/], + ["2026.7.1", /^agent-tools-.*\.js$/], ]); type RuntimeFunctionName = (typeof RUNTIME_FUNCTION_NAMES)[number]; type ExpectedMode = "progressive" | "direct"; diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index ffd14f67bd0..461a6befac5 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -19,6 +19,7 @@ import { import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; +import { remediateReviewedOpenClawPluginArchive } from "../../../../../scripts/lib/openclaw-npm-remediation.mts"; import { packReviewedNpmArchive } from "../../../../../scripts/lib/reviewed-npm-archive.mts"; import { discordManifest } from "../../channels/discord/manifest.ts"; import { slackManifest } from "../../channels/slack/manifest.ts"; @@ -1296,7 +1297,14 @@ function packVerifiedOpenClawPluginArchive( packageSpec: install.npmPackageSpec, tarballUrl: install.tarballUrl, }); - return { archivePath: archive.archivePath, rootDir: archive.rootDirectory }; + const exactPackage = requireExactNpmPackageSpec(install.spec, install.npmPackageSpec); + const remediated = remediateReviewedOpenClawPluginArchive({ + archivePath: archive.archivePath, + env: env as NodeJS.ProcessEnv, + packageSpec: `${exactPackage.packageSpec}@${exactPackage.version}`, + workingDirectory: archive.rootDirectory, + }); + return { archivePath: remediated.archivePath, rootDir: archive.rootDirectory }; } type CredentialPlaceholderRule = { diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 59d6f283995..e7c1cdc4584 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -195,11 +195,11 @@ export const discordManifest = { spec: "npm:@openclaw/discord@{{openclaw.version}}", pin: true, integrityByVersion: { - "2026.6.10": - "sha512-NKp/j00l+rk5PC0Lv/0fOIiiQJ1c/OpG9471zqXUDKQie6pQ1Fi9KUZUouyoTMmfLh/n4S0CkEMqrON40eBKXA==", + "2026.7.1": + "sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg==", }, tarballUrlByVersion: { - "2026.6.10": "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz", + "2026.7.1": "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz", }, required: true, }, diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index f4107b3a8cb..19a51d39fc6 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -193,7 +193,7 @@ describe("built-in messaging channel metadata", () => { .map((agentPackage) => ({ packageKey: `${manifest.id}/${agentPackage.id}`, committedIntegrity: - agentPackage.integrity ?? agentPackage.integrityByVersion?.["2026.6.10"], + agentPackage.integrity ?? agentPackage.integrityByVersion?.["2026.7.1"], })), ); @@ -201,7 +201,7 @@ describe("built-in messaging channel metadata", () => { { packageKey: "discord/openclawPluginPackage", committedIntegrity: - "sha512-NKp/j00l+rk5PC0Lv/0fOIiiQJ1c/OpG9471zqXUDKQie6pQ1Fi9KUZUouyoTMmfLh/n4S0CkEMqrON40eBKXA==", + "sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg==", }, { packageKey: "wechat/openclawPluginPackage", @@ -211,17 +211,17 @@ describe("built-in messaging channel metadata", () => { { packageKey: "slack/openclawPluginPackage", committedIntegrity: - "sha512-OOsMLjPcbWhQRM5XDwfdrACjJmKqavFtpuIlhHAXWrLrd/p7SyIVE9AoKS0yxOx6bqGDIMJ9+knzdViHMLgBdA==", + "sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA==", }, { packageKey: "whatsapp/openclawPluginPackage", committedIntegrity: - "sha512-k/XrRdZY77SHrdaRwJOEB7/JRbjp4yVgGD/ZNyakjTMqo32XRVtwPBUnj7726rW8Kl5yyOMQQLKFiD9MDfhmPQ==", + "sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ==", }, { packageKey: "teams/openclawPluginPackage", committedIntegrity: - "sha512-GjHnCPvjbnI0C7mEFcdT2uKDH4/WwOe2dZBfQiWxBtkE76m6TNG0J9dJjD4mc8/pk8rXSO0cWw+KV9jzWtF9VA==", + "sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg==", }, ]); }); diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index fd254f230bc..c03df6eea9f 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -214,11 +214,11 @@ export const slackManifest = { spec: "npm:@openclaw/slack@{{openclaw.version}}", pin: true, integrityByVersion: { - "2026.6.10": - "sha512-OOsMLjPcbWhQRM5XDwfdrACjJmKqavFtpuIlhHAXWrLrd/p7SyIVE9AoKS0yxOx6bqGDIMJ9+knzdViHMLgBdA==", + "2026.7.1": + "sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA==", }, tarballUrlByVersion: { - "2026.6.10": "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.6.10.tgz", + "2026.7.1": "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz", }, required: true, }, diff --git a/src/lib/messaging/channels/teams/manifest.ts b/src/lib/messaging/channels/teams/manifest.ts index e54f6eeab5c..b8e84026692 100644 --- a/src/lib/messaging/channels/teams/manifest.ts +++ b/src/lib/messaging/channels/teams/manifest.ts @@ -202,11 +202,11 @@ export const teamsManifest = { spec: "npm:@openclaw/msteams@{{openclaw.version}}", pin: true, integrityByVersion: { - "2026.6.10": - "sha512-GjHnCPvjbnI0C7mEFcdT2uKDH4/WwOe2dZBfQiWxBtkE76m6TNG0J9dJjD4mc8/pk8rXSO0cWw+KV9jzWtF9VA==", + "2026.7.1": + "sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg==", }, tarballUrlByVersion: { - "2026.6.10": "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz", + "2026.7.1": "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz", }, required: true, }, diff --git a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts index 9bc215c7285..c169fd080a5 100644 --- a/src/lib/messaging/channels/whatsapp/hooks/status-health.ts +++ b/src/lib/messaging/channels/whatsapp/hooks/status-health.ts @@ -310,7 +310,7 @@ type WhatsappStateLookup = | { readonly kind: "invalid" }; /** - * OpenClaw 2026.6.10 exposes live per-account state under + * OpenClaw 2026.7.1 exposes live per-account state under * `channelAccounts.whatsapp` and names the authoritative account through * `channelDefaultAccountId.whatsapp`. Select that exact account rather than * trusting array order or the channel-level summary. Every supported OpenClaw diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index b35eb0e3e18..b6fa79d3fff 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -111,11 +111,11 @@ export const whatsappManifest = { spec: "npm:@openclaw/whatsapp@{{openclaw.version}}", pin: true, integrityByVersion: { - "2026.6.10": - "sha512-k/XrRdZY77SHrdaRwJOEB7/JRbjp4yVgGD/ZNyakjTMqo32XRVtwPBUnj7726rW8Kl5yyOMQQLKFiD9MDfhmPQ==", + "2026.7.1": + "sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ==", }, tarballUrlByVersion: { - "2026.6.10": "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.6.10.tgz", + "2026.7.1": "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz", }, required: true, }, diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts index 0d2e82f44c2..74f00192864 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.test.ts @@ -125,7 +125,7 @@ describe("patchOpenClawQrTerminalRendererSource (#4522)", () => { expect(isOpenClawQrTerminalRendererSource("const unrelated = true;")).toBe(false); }); - it("recognizes the reviewed OpenClaw 2026.6.10 renderer integrity", () => { + it("recognizes the reviewed OpenClaw 2026.7.1 renderer integrity", () => { expect( isReviewedOpenClawQrTerminalRendererIntegrity(REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256), ).toBe(true); diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts index 3b8d18fbd37..4a54e13c386 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts @@ -34,7 +34,7 @@ // fix also covers any agent/path that renders through that package. // * the reviewed OpenClaw QR renderer ES module: widen the compact-renderer // quiet zone on all four edges. This is hash-gated to the reviewed -// OpenClaw 2026.6.10 renderer so a drifted upstream bundle fails closed. +// OpenClaw 2026.7.1 renderer so a drifted upstream bundle fails closed. // The QR text and error-correction level are never altered — only the // terminal cell packing — so the rendered code is identical apart from size. // @@ -72,7 +72,7 @@ function isOpenClawQrTerminalRendererSource(source) { } var REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256 = - "f74865035a498389fe910b23537a7dffeaee1b05e044999d855b61c96af0ada7"; + "73ba96f98d89c1abbba5d41e72ffd2b10658a37f33c31c6a9775eb40df06afb9"; function isReviewedOpenClawQrTerminalRendererIntegrity(integrity: string | undefined) { return integrity === REVIEWED_OPENCLAW_QR_TERMINAL_RENDERER_SHA256; diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 5c34a282d4f..4ee4673d8ed 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -261,6 +261,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "lib", "reviewed-npm-archive.mts"), path.join(stagedScriptsDir, "lib", "reviewed-npm-archive.mts"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "lib", "openclaw-npm-remediation.mts"), + path.join(stagedScriptsDir, "lib", "openclaw-npm-remediation.mts"), + ); normalizeReadModesForDockerCopy(stagedScriptsDir); return { buildCtx, stagedDockerfile }; diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 6e13572c3ab..573c5238edd 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -907,7 +907,7 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); ); check( installedSlackProof.proof === "openclaw-pipeline-runtime", - `M-S17c: OpenClaw 2026.6.10 Slack proof used the reviewed pipeline/runtime exports (${installedSlackProof.proof})`, + `M-S17c: OpenClaw 2026.7.1 Slack proof used the reviewed pipeline/runtime exports (${installedSlackProof.proof})`, ); const slackRuntimeCapture = lastJsonLine( fakeSlack.captureFile, diff --git a/test/e2e/live/openclaw-tui-chat-correlation.test.ts b/test/e2e/live/openclaw-tui-chat-correlation.test.ts index 69a992369c5..4a01b30cf58 100644 --- a/test/e2e/live/openclaw-tui-chat-correlation.test.ts +++ b/test/e2e/live/openclaw-tui-chat-correlation.test.ts @@ -58,12 +58,12 @@ import { const ENVIRONMENT = ubuntuRepoDocker("cloud-openclaw"); const SANDBOX_NAME = "e2e-openclaw-tui-corr"; -// OpenClaw 2026.6.10 is the post-fix regression-guard version for #2603 + #3145. +// OpenClaw 2026.7.1 is the post-fix regression-guard version for #2603 + #3145. // Historical buggy builds were older; this live guard asserts the fixed // protocol/history contract stays stable on the pinned OpenClaw version. // Override via env so future pin bumps do not require a code edit. const EXPECTED_OPENCLAW_VERSION = - process.env.E2E_OPENCLAW_TUI_CORRELATION_PINNED_VERSION ?? "2026.6.10"; + process.env.E2E_OPENCLAW_TUI_CORRELATION_PINNED_VERSION ?? "2026.7.1"; const LIVE_SCRIPT_NAME = "openclaw-issue2603-chat-correlation.cjs"; const SANDBOX_GATEWAY_PORT = 18789; diff --git a/test/e2e/live/openshell-gateway-auth-source-contract-helpers.ts b/test/e2e/live/openshell-gateway-auth-source-contract-helpers.ts index 2ac00bd15f3..9e05afe39a8 100644 --- a/test/e2e/live/openshell-gateway-auth-source-contract-helpers.ts +++ b/test/e2e/live/openshell-gateway-auth-source-contract-helpers.ts @@ -20,7 +20,7 @@ import { expect } from "../fixtures/e2e-test.ts"; const SANDBOX_JWT_SUBJECT_PREFIX = "spiffe://openshell/sandbox/"; const DOCKER_GRPC_PROBE_IMAGE = - "node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d"; + "node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba"; const FORBIDDEN_AUTH_ARTIFACT_CONTENT: Array<{ label: string; pattern: RegExp }> = [ { label: "authorization header", pattern: /["']?authorization["']?\s*[:=]/i }, diff --git a/test/e2e/live/snapshot-credential-scanner.ts b/test/e2e/live/snapshot-credential-scanner.ts index caee53e79c7..95931a3c94d 100644 --- a/test/e2e/live/snapshot-credential-scanner.ts +++ b/test/e2e/live/snapshot-credential-scanner.ts @@ -20,7 +20,7 @@ const ENV_ASSIGNMENT_PATTERN = /^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=/gm; const STRUCTURED_CREDENTIAL_KEY_PATTERN = /["']?(?:apiKey|api_key|accessToken|access_token|secretKey|secret_key|bearerToken|bearer_token)["']?\s*[:=]\s*["'][^"']+["']/i; -// OpenClaw 2026.6.10 persists an environment variable name, rather than its +// OpenClaw 2026.7.1 persists an environment variable name, rather than its // resolved value, in generated agents/*/agent/models.json provider entries. // Keep bare/braced names bounded to provider credentials used by NemoClaw or // OpenClaw's ambient AWS auth. An explicitly prefixed secretref-env marker can diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index e0fe9ebf81e..5528f3c6c2b 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -196,7 +196,7 @@ describe("messaging provider installed-runtime proofs", () => { fs.mkdirSync(slackPackageRoot, { recursive: true }); fs.writeFileSync( path.join(slackProject, "package.json"), - JSON.stringify({ dependencies: { "@openclaw/slack": "2026.6.10" } }), + JSON.stringify({ dependencies: { "@openclaw/slack": "2026.7.1" } }), ); fs.mkdirSync(path.join(unrelatedProject, "node_modules", "@openclaw", "slack"), { recursive: true, @@ -270,7 +270,7 @@ describe("messaging provider installed-runtime proofs", () => { ); }); - it("requires the reviewed Slack pipeline/runtime proof in the default 2026.6.10 live lane", () => { + it("requires the reviewed Slack pipeline/runtime proof in the default 2026.7.1 live lane", () => { expect(LIVE_MESSAGING_PROVIDERS_SOURCE).toContain( 'installedSlackProof.proof === "openclaw-pipeline-runtime"', ); diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts index 60f2bb27831..5d3600bcaa0 100644 --- a/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts +++ b/test/e2e/support/openclaw-plugin-runtime-exdev-workflow-boundary.test.ts @@ -85,7 +85,7 @@ describe("OpenClaw plugin runtime EXDEV workflow boundary", () => { "openclaw-plugin-runtime-exdev action 'actions/checkout@v6' must pin a full SHA", "openclaw-plugin-runtime-exdev checkout must disable persisted credentials", "openclaw-plugin-runtime-exdev must use the reviewed prepare-e2e action", - "openclaw-plugin-runtime-exdev step 'Pre-pull release-matched Docker Hub builder image' must run: docker pull node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d", + "openclaw-plugin-runtime-exdev step 'Pre-pull release-matched Docker Hub builder image' must run: docker pull node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba", "openclaw-plugin-runtime-exdev must always revoke Docker auth before the release-pinned fixture", "openclaw-plugin-runtime-exdev step 'Remove Docker auth before release-pinned fixture' must run: bash .github/scripts/docker-auth-cleanup.sh", "openclaw-plugin-runtime-exdev runtime proof must not receive workflow credentials", diff --git a/test/e2e/support/openshell-gateway-auth-contract-workflow-boundary.test.ts b/test/e2e/support/openshell-gateway-auth-contract-workflow-boundary.test.ts index d2c45eac697..461d6ef3a71 100644 --- a/test/e2e/support/openshell-gateway-auth-contract-workflow-boundary.test.ts +++ b/test/e2e/support/openshell-gateway-auth-contract-workflow-boundary.test.ts @@ -59,7 +59,7 @@ describe("OpenShell gateway auth contract workflow boundary", () => { "openshell-gateway-auth-contract must run only when explicitly selected", "openshell-gateway-auth-contract must run on ubuntu-latest", "openshell-gateway-auth-contract must retain its 20 minute resource budget", - "openshell-gateway-auth-contract must set DOCKER_GRPC_PROBE_IMAGE=node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d", + "openshell-gateway-auth-contract must set DOCKER_GRPC_PROBE_IMAGE=node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba", "openshell-gateway-auth-contract must set E2E_ARTIFACT_DIR=${{ github.workspace }}/e2e-artifacts/live/openshell-gateway-auth-contract", "openshell-gateway-auth-contract must set E2E_DEFAULT_ENABLED=0", "openshell-gateway-auth-contract must set NEMOCLAW_OPENSHELL_PIN_VERSION to an exact version", diff --git a/test/effective-policy-contracts.test.ts b/test/effective-policy-contracts.test.ts index 3b62b21b59e..c6381618309 100644 --- a/test/effective-policy-contracts.test.ts +++ b/test/effective-policy-contracts.test.ts @@ -187,7 +187,7 @@ describe("effective built-in policy contracts", () => { expect( loadAgent("openclaw").expectedVersion, "Revalidate the bundled OpenClaw weather skill before changing its reviewed egress contract", - ).toBe("2026.6.10"); + ).toBe("2026.7.1"); }); it("uses raw L4 tunnels only for protocols that cannot be REST-inspected", () => { diff --git a/test/fetch-guard-patch-regression.test.ts b/test/fetch-guard-patch-regression.test.ts index 22eaca757a2..41654478eb5 100644 --- a/test/fetch-guard-patch-regression.test.ts +++ b/test/fetch-guard-patch-regression.test.ts @@ -21,11 +21,11 @@ const REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSIONS = [ "2026.5.18", "2026.5.22", "2026.5.27", - "2026.6.10", + "2026.7.1", ] as const; const EXPECTED_OPENCLAW_INTEGRITY = - "sha512-LcooND2tBQw8A+kc1Ujltu3lg30bJ0w7XaeRy7eYzobb8BBdcW6DOGbwJL4vpj1vl9+gjRceOtlh5nh9OARcug=="; -const REVIEWED_OPENCLAW_2026_6_10_WEB_FETCH_SHAPE = [ + "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g=="; +const REVIEWED_OPENCLAW_2026_7_1_WEB_FETCH_SHAPE = [ "async function fetchWithWebToolsNetworkGuard(params) {", " const { timeoutSeconds, useEnvProxy, ...rest } = params;", " const resolved = {", @@ -38,8 +38,8 @@ const REVIEWED_OPENCLAW_2026_6_10_WEB_FETCH_SHAPE = [ " return fetchWithSsrFGuard(useEnvProxy ? withTrustedEnvProxyGuardedFetchMode(resolved) : withStrictGuardedFetchMode(resolved));", "}", ].join("\n"); -const REVIEWED_OPENCLAW_2026_6_10_MANAGED_PROXY_SHAPE = - "const canUseManagedProxy = mode === GUARDED_FETCH_MODE.STRICT && isManagedProxyActive() && hasProxyEnvConfigured();"; +const REVIEWED_OPENCLAW_2026_7_1_MANAGED_PROXY_SHAPE = + "const isStrictManagedProxyActive = mode === GUARDED_FETCH_MODE.STRICT && isManagedProxyActive();"; function readRequiredMatch(file: string, pattern: RegExp, description: string): string { const match = fs.readFileSync(file, "utf-8").match(pattern); if (!match?.[1]) { @@ -109,7 +109,7 @@ function readDockerfileMcporterIntegrity(): string { function readDockerfileBaseOpenClawIntegrity(): string { return readRequiredMatch( DOCKERFILE_BASE, - /^ARG OPENCLAW_2026_6_10_INTEGRITY=([^\s]+)/m, + /^ARG OPENCLAW_2026_7_1_INTEGRITY=([^\s]+)/m, "OpenClaw base image integrity", ); } @@ -117,7 +117,7 @@ function readDockerfileBaseOpenClawIntegrity(): string { function readDockerfileOpenClawIntegrity(): string { return readRequiredMatch( DOCKERFILE, - /^ARG OPENCLAW_2026_6_10_INTEGRITY=([^\s]+)/m, + /^ARG OPENCLAW_2026_7_1_INTEGRITY=([^\s]+)/m, "OpenClaw runtime integrity", ); } @@ -125,7 +125,7 @@ function readDockerfileOpenClawIntegrity(): string { function readDockerfileOpenClawTarball(): string { return readRequiredMatch( DOCKERFILE, - /^ARG OPENCLAW_2026_6_10_TARBALL=([^\s]+)/m, + /^ARG OPENCLAW_2026_7_1_TARBALL=([^\s]+)/m, "OpenClaw runtime tarball", ); } @@ -178,8 +178,8 @@ function runOpenClawUpgradeBlock(currentVersion: string) { `OPENCLAW_VERSION=${JSON.stringify(openclawVersion)}`, `BASE_IMAGE=${JSON.stringify("registry.example/nemoclaw-test-base:latest")}`, `MCPORTER_VERSION=${JSON.stringify(expectedMcporterVersion)}`, - `OPENCLAW_2026_6_10_INTEGRITY=${JSON.stringify(openclawIntegrity)}`, - `OPENCLAW_2026_6_10_TARBALL=${JSON.stringify(openclawTarball)}`, + `OPENCLAW_2026_7_1_INTEGRITY=${JSON.stringify(openclawIntegrity)}`, + `OPENCLAW_2026_7_1_TARBALL=${JSON.stringify(openclawTarball)}`, `MCPORTER_0_7_3_INTEGRITY=${JSON.stringify(mcporterIntegrity)}`, `MCPORTER_0_7_3_TARBALL=${JSON.stringify(mcporterTarball)}`, "node() {", @@ -193,8 +193,8 @@ function runOpenClawUpgradeBlock(currentVersion: string) { " return 0;", " fi", ' [ "$#" -eq 10 ] && [ "${3:-}" = "--package-spec" ] && [ "${4:-}" = "openclaw@${OPENCLAW_VERSION}" ] || return 95;', - ' [ "${5:-}" = "--integrity" ] && [ "${6:-}" = "$OPENCLAW_2026_6_10_INTEGRITY" ] || return 96;', - ' [ "${7:-}" = "--tarball-url" ] && [ "${8:-}" = "$OPENCLAW_2026_6_10_TARBALL" ] || return 97;', + ' [ "${5:-}" = "--integrity" ] && [ "${6:-}" = "$OPENCLAW_2026_7_1_INTEGRITY" ] || return 96;', + ' [ "${7:-}" = "--tarball-url" ] && [ "${8:-}" = "$OPENCLAW_2026_7_1_TARBALL" ] || return 97;', ' [ "${9:-}" = "--label" ] && [ "${10:-}" = "OpenClaw ${OPENCLAW_VERSION}" ] || return 98;', ' printf "npm pack %s --pack-destination reviewed-temp\\n" "${8:-}" >> "$call_log";', ' printf "%s\\n" "$reviewed_archive"; return 0;', @@ -206,7 +206,7 @@ function runOpenClawUpgradeBlock(currentVersion: string) { "npm() {", ' printf "npm %s\\n" "$*" >> "$call_log";', ' if [ "${1:-}" = "view" ] && [ "${2:-}" = "openclaw@${OPENCLAW_VERSION}" ] && [ "${3:-}" = "dist.integrity" ]; then', - ' printf "%s\\n" "$OPENCLAW_2026_6_10_INTEGRITY";', + ' printf "%s\\n" "$OPENCLAW_2026_7_1_INTEGRITY";', " return 0", " fi", ' if [ "${1:-}" = "view" ] && [ "${2:-}" = "mcporter@${MCPORTER_VERSION}" ] && [ "${3:-}" = "dist.integrity" ]; then', @@ -214,7 +214,7 @@ function runOpenClawUpgradeBlock(currentVersion: string) { " return 0", " fi", ' if [ "${1:-}" = "view" ] && [ "${2:-}" = "openclaw@${OPENCLAW_VERSION}" ] && [ "${3:-}" = "dist.tarball" ]; then', - ' printf "%s\\n" "$OPENCLAW_2026_6_10_TARBALL";', + ' printf "%s\\n" "$OPENCLAW_2026_7_1_TARBALL";', " return 0", " fi", ' if [ "${1:-}" = "pack" ]; then', @@ -226,7 +226,7 @@ function runOpenClawUpgradeBlock(currentVersion: string) { ' test -n "$pack_dir";', ' pack_file="openclaw-${OPENCLAW_VERSION}.tgz";', ' printf "fake openclaw tarball" > "$pack_dir/$pack_file";', - ' printf \'[{"filename":"%s","integrity":"%s"}]\\n\' "$pack_file" "$OPENCLAW_2026_6_10_INTEGRITY";', + ' printf \'[{"filename":"%s","integrity":"%s"}]\\n\' "$pack_file" "$OPENCLAW_2026_7_1_INTEGRITY";', " return 0", " fi", ' if [ "${1:-}" = "install" ]; then return 0; fi', @@ -278,11 +278,11 @@ function webGuardedFetchFixtureSource(): string { } describe("fetch-guard patch regression guard", () => { - it("anchors web_fetch proxy mode to the reviewed OpenClaw 2026.6.10 contract", () => { - expect(REVIEWED_OPENCLAW_2026_6_10_WEB_FETCH_SHAPE).toContain( + it("anchors web_fetch proxy mode to the reviewed OpenClaw 2026.7.1 contract", () => { + expect(REVIEWED_OPENCLAW_2026_7_1_WEB_FETCH_SHAPE).toContain( "function fetchWithWebToolsNetworkGuard(params)", ); - expect(REVIEWED_OPENCLAW_2026_6_10_WEB_FETCH_SHAPE).toContain( + expect(REVIEWED_OPENCLAW_2026_7_1_WEB_FETCH_SHAPE).toContain( "withTrustedEnvProxyGuardedFetchMode(resolved)", ); }); @@ -360,7 +360,7 @@ describe("fetch-guard patch regression guard", () => { `openclaw-${CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION}.tgz`, ); - const newer = runOpenClawUpgradeBlock("2026.6.11"); + const newer = runOpenClawUpgradeBlock("2026.7.2"); expect(newer.result.status).toBe(1); expect(newer.result.stderr).toContain( "newer than reviewed target " + CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION, @@ -1117,7 +1117,9 @@ if (!blocked) throw new Error('private IP literal was not blocked');`, "function isManagedProxyActive() { return process.env.OPENCLAW_PROXY_ACTIVE === '1'; }", "function hasProxyEnvConfigured() { return true; }", "function computeCanUseManagedProxy(mode, params) {", - ` ${REVIEWED_OPENCLAW_2026_6_10_MANAGED_PROXY_SHAPE}`, + " const dispatcherPolicy = params.dispatcherPolicy;", + ` ${REVIEWED_OPENCLAW_2026_7_1_MANAGED_PROXY_SHAPE}`, + " const canUseManagedProxy = isStrictManagedProxyActive && hasProxyEnvConfigured();", " return canUseManagedProxy;", "}", "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b, computeCanUseManagedProxy as g };", @@ -1322,7 +1324,7 @@ if (!blocked) throw new Error('private IP literal was not blocked');`, const patch = runFetchGuardPatchBlock(dist, tmp); expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); expect(patch.stdout).toContain( - "Patch 6 applied to OpenClaw 2026.6.10 cron preflight trusted env-proxy", + "Patch 6 applied to OpenClaw 2026.7.1 cron preflight trusted env-proxy", ); const patched = fs.readFileSync(preflightPath, "utf-8"); expect( @@ -1375,7 +1377,7 @@ if (!blocked) throw new Error('private IP literal was not blocked');`, const patch = runFetchGuardPatchBlock(dist, tmp); expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); expect(patch.stdout).toContain( - "OpenClaw 2026.6.10 has no cron model-provider preflight; Patch 6 not needed", + "OpenClaw 2026.7.1 has no cron model-provider preflight; Patch 6 not needed", ); } finally { fs.rmSync(tmp, { recursive: true, force: true }); diff --git a/test/helpers/fetch-guard-patch-harness.ts b/test/helpers/fetch-guard-patch-harness.ts index ebee9fd401b..b3cfc0d9da6 100644 --- a/test/helpers/fetch-guard-patch-harness.ts +++ b/test/helpers/fetch-guard-patch-harness.ts @@ -7,7 +7,7 @@ import path from "node:path"; const DOCKERFILE = path.join(import.meta.dirname, "..", "..", "Dockerfile"); -export const CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION = "2026.6.10"; +export const CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION = "2026.7.1"; export function dockerRunCommandBetween(startMarker: string, endMarker: string): string { const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); diff --git a/test/helpers/openclaw-device-self-approval-patch-harness.ts b/test/helpers/openclaw-device-self-approval-patch-harness.ts index c68b87c0ed7..a962783f697 100644 --- a/test/helpers/openclaw-device-self-approval-patch-harness.ts +++ b/test/helpers/openclaw-device-self-approval-patch-harness.ts @@ -176,6 +176,7 @@ function requestsNonOperatorDeviceRole(pending) { const roles = new Set([...(pending.roles ?? []), ...(pending.role ? [pending.role] : [])]); return [...roles].some((role) => role !== "operator"); } + function emitDevicePairingDeniedSecurityEvent() {} function emitDevicePairingLifecycleSecurityEvent() {} function formatDevicePairingForbiddenMessage(value) { return value.reason; } @@ -243,6 +244,63 @@ const deviceHandlers = { `); } +function gatewayAuthFixture(): string { + return compiledIndent(` +const GATEWAY_CLIENT_IDS = { CLI: "cli" }; +const GATEWAY_CLIENT_MODES = { CLI: "cli" }; +function mapDeviceTokenAuthFailureReason() { return "device_token_mismatch"; } +async function resolveConnectAuthDecisionCore(params) { + let authResult = params.state.authResult; + let authOk = params.state.authOk; + let authMethod = params.state.authMethod; + let deviceTokenSharedGatewaySessionGeneration; + function finish() { return { authResult, authOk, authMethod, deviceTokenSharedGatewaySessionGeneration }; } + const deviceTokenCandidate = params.state.deviceTokenCandidate; + if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) return finish(); + const tokenCheck = await params.verifyDeviceToken({ + deviceId: params.deviceId, + token: deviceTokenCandidate, + role: params.role, + scopes: params.scopes + }); + if (tokenCheck.ok) { + authOk = true; + authMethod = "device-token"; + if (tokenCheck.issuer?.kind === "shared-gateway-auth") deviceTokenSharedGatewaySessionGeneration = tokenCheck.issuer.generation; + params.rateLimiter?.reset(params.clientIp, "device-token"); + } else { + authResult = { ok: false, reason: mapDeviceTokenAuthFailureReason() }; + params.rateLimiter?.recordFailure(params.clientIp, "device-token"); + } + return finish(); +} +async function resolveConnectAuthDecision(params) { return resolveConnectAuthDecisionCore(params); } +async function connect(connectParams, verifyDeviceToken) { + const role = connectParams.role; + const scopes = connectParams.scopes; + const authRateLimiter = null; + const authDecision = await resolveConnectAuthDecision({ + state: { + authResult: { ok: false }, + authOk: false, + authMethod: "token", + deviceTokenCandidate: "stored-token" + }, + hasDeviceIdentity: true, + deviceId: "device-1", + publicKey: "public-key-1", + role, + scopes, + rateLimiter: authRateLimiter, + clientIp: "127.0.0.1", + verifyBootstrapToken: async () => ({ ok: false }), + verifyDeviceToken: async (paramsLocal) => await verifyDeviceToken(paramsLocal) + }); + return authDecision; +} +`); +} + function stateFixture(): string { return compiledIndent(` const PENDING_TTL_MS = 300 * 1e3; @@ -434,6 +492,7 @@ async function approveBootstrapDevicePairing(requestId, bootstrapProfile, option export function writeFixtureDist(dist: string): void { fs.writeFileSync(path.join(dist, "devices-cli.runtime-fixture.js"), cliFixture()); + fs.writeFileSync(path.join(dist, "message-handler-fixture.js"), gatewayAuthFixture()); fs.writeFileSync(path.join(dist, "devices-fixture.js"), handlerFixture()); fs.writeFileSync(path.join(dist, "device-pairing-fixture.js"), stateFixture()); } diff --git a/test/helpers/openclaw-real-device-self-approval-proof.ts b/test/helpers/openclaw-real-device-self-approval-proof.ts index 340f208245e..2ba70b72e21 100644 --- a/test/helpers/openclaw-real-device-self-approval-proof.ts +++ b/test/helpers/openclaw-real-device-self-approval-proof.ts @@ -893,12 +893,12 @@ function gatewayLogDetail(logFile: string, secret: string): string { return log.slice(-20_000).replaceAll(secret, ""); } -async function runLiveConfigTokenSelfApprovalProof(options: ProofOptions): Promise { +async function runLiveStoredDeviceAuthSelfApprovalProof(options: ProofOptions): Promise { const packageDir = path.dirname(options.dist); const openclawEntry = path.join(packageDir, "openclaw.mjs"); requireLiveProof(fs.existsSync(openclawEntry), "reviewed OpenClaw CLI entrypoint missing"); - const liveRoot = path.join(options.tmp, "device-approval-live-config-token"); + const liveRoot = path.join(options.tmp, "device-approval-live-stored-auth"); const stateDir = path.join(liveRoot, "state"); const homeDir = path.join(liveRoot, "home"); const configPath = path.join(liveRoot, "openclaw.json"); @@ -907,17 +907,19 @@ async function runLiveConfigTokenSelfApprovalProof(options: ProofOptions): Promi fs.mkdirSync(homeDir, { recursive: true }); const port = await reserveLoopbackPort(); const gatewayToken = crypto.randomBytes(32).toString("hex"); - fs.writeFileSync( - configPath, - JSON.stringify({ - gateway: { - mode: "local", - bind: "loopback", - port, - auth: { mode: "token", token: gatewayToken }, - }, - }), - ); + const writeGatewayConfig = (auth: Record) => + fs.writeFileSync( + configPath, + JSON.stringify({ + gateway: { + mode: "local", + bind: "loopback", + port, + auth, + }, + }), + ); + writeGatewayConfig({ mode: "none" }); const { OPENCLAW_GATEWAY_PASSWORD: _gatewayPassword, OPENCLAW_GATEWAY_PORT: _gatewayPort, @@ -944,21 +946,22 @@ async function runLiveConfigTokenSelfApprovalProof(options: ProofOptions): Promi timeout: Math.min(options.timeoutMs, 60_000), }); - const gatewayLogFd = fs.openSync(gatewayLog, "w"); - const gateway = spawn(process.execPath, [openclawEntry, "gateway", "run"], { - cwd: packageDir, - env, - stdio: ["ignore", gatewayLogFd, gatewayLogFd], - }); - fs.closeSync(gatewayLogFd); + const startGateway = (gatewayEnv: NodeJS.ProcessEnv, append: boolean) => { + const gatewayLogFd = fs.openSync(gatewayLog, append ? "a" : "w"); + const child = spawn(process.execPath, [openclawEntry, "gateway", "run"], { + cwd: packageDir, + env: gatewayEnv, + stdio: ["ignore", gatewayLogFd, gatewayLogFd], + }); + fs.closeSync(gatewayLogFd); + return child; + }; + let gateway = startGateway(env, false); try { await waitForGatewayReady(gateway, port, options.timeoutMs); const bootstrap = runCli(["devices", "list", "--json"]); - requireSuccess( - bootstrap, - "bootstrap real stored device identity with configured gateway token", - ); + requireSuccess(bootstrap, "bootstrap real stored device identity through local pairing"); const deviceAuthPath = path.join(stateDir, "identity", "device-auth.json"); const identityPath = path.join(stateDir, "identity", "device.json"); const authStore = readJsonObject(deviceAuthPath, "real stored device auth"); @@ -998,6 +1001,11 @@ async function runLiveConfigTokenSelfApprovalProof(options: ProofOptions): Promi "stored device credential does not match the server pairing token before repair", ); + await stopChild(gateway); + writeGatewayConfig({ mode: "token" }); + gateway = startGateway({ ...env, OPENCLAW_GATEWAY_TOKEN: gatewayToken }, true); + await waitForGatewayReady(gateway, port, options.timeoutMs); + const createSession = runCli([ "gateway", "call", @@ -1023,7 +1031,13 @@ async function runLiveConfigTokenSelfApprovalProof(options: ProofOptions): Promi ); requireLiveProof( repairRequests.length === 1, - `expected one exact real same-device repair, found ${repairRequests.length}`, + [ + `expected one exact real same-device repair, found ${repairRequests.length}`, + `trigger status: ${String(createSession.status)}`, + `trigger stdout: ${String(createSession.stdout).trim()}`, + `trigger stderr: ${String(createSession.stderr).trim()}`, + `pending state: ${JSON.stringify(pending)}`, + ].join("\n"), ); const repair = repairRequests[0] as Record; requireLiveProof( @@ -1046,15 +1060,12 @@ async function runLiveConfigTokenSelfApprovalProof(options: ProofOptions): Promi const configuredGateway = asRecord(configuredBeforeApproval.gateway); const configuredAuth = asRecord(configuredGateway?.auth); requireLiveProof( - configuredAuth?.token === gatewayToken, - "configured shared gateway token disappeared before approval", + configuredAuth?.mode === "token" && configuredAuth.token === undefined, + "gateway token auth was not isolated from the stored-device-auth client", ); const approval = runCli(["devices", "approve", String(repair.requestId), "--json"]); - requireSuccess( - approval, - "approve real same-device repair with configured shared token present", - ); + requireSuccess(approval, "approve real same-device repair with stored device auth"); const pendingAfter = readJsonObject(pendingPath, "real pending state after approval"); requireLiveProof( @@ -1100,8 +1111,8 @@ async function runLiveConfigTokenSelfApprovalProof(options: ProofOptions): Promi const configuredGatewayAfter = asRecord(configuredAfterApproval.gateway); const configuredAuthAfter = asRecord(configuredGatewayAfter?.auth); requireLiveProof( - configuredAuthAfter?.token === gatewayToken, - "configured shared gateway token changed during stored-device-auth approval", + configuredAuthAfter?.mode === "token" && configuredAuthAfter.token === undefined, + "gateway token auth configuration changed during stored-device-auth approval", ); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -1141,9 +1152,10 @@ export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptio requireSuccess(audit, "audit bounded device self-approval patch"); for (const marker of [ "devices CLI approval runtime:", + "device-token scope-upgrade gateway auth runtime:", "device pairing gateway handler:", "canonical device pairing state runtime:", - "Summary: 3 OK · 0 missing", + "Summary: 4 OK · 0 missing", ]) { requireIncludes(audit.stdout, marker, "device self-approval audit"); } @@ -1151,6 +1163,7 @@ export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptio const sources = readDistSources(options.dist); for (const marker of [ "nemoclaw: reach gateway for bounded same-device scope approval", + "nemoclaw: route bounded CLI device-token scope upgrade into pairing", "nemoclaw: bounded same-device scope approval", "nemoclaw: validate bounded self-approval inside pairing lock", 'CLI: "cli"', @@ -1462,5 +1475,5 @@ if (!["operator.pairing", "operator.read", "operator.write"].every((scope) => sc runPairingCrashDirectionProof(options, deviceBootstrapUrl, journalBasename, "pending"); runPairingCrashDirectionProof(options, deviceBootstrapUrl, journalBasename, "paired"); runRejectedRenameRollbackProof(options, deviceBootstrapUrl, journalBasename); - await runLiveConfigTokenSelfApprovalProof(options); + await runLiveStoredDeviceAuthSelfApprovalProof(options); } diff --git a/test/issue-4434-error-fields.test.ts b/test/issue-4434-error-fields.test.ts index 583511d8141..dce22ea8776 100644 --- a/test/issue-4434-error-fields.test.ts +++ b/test/issue-4434-error-fields.test.ts @@ -14,15 +14,15 @@ const REPO_ROOT = path.join(import.meta.dirname, ".."); const DOCKERFILE = path.join(REPO_ROOT, "Dockerfile"); const DEPENDENCY_REVIEW = path.join( REPO_ROOT, - "docs/security/openclaw-2026.6.10-dependency-review.md", + "docs/security/openclaw-2026.7.1-dependency-review.md", ); const LIVE_VITEST_GUARD = path.join( REPO_ROOT, "test/e2e/live/issue-4434-tui-unreachable-inference.test.ts", ); -const CURRENT_REVIEWED_OPENCLAW_VERSION = "2026.6.10"; -const PATCHED_OPENCLAW_2026_6_10_ISSUE_4434_TUI_ERROR_OUTPUT = [ +const CURRENT_REVIEWED_OPENCLAW_VERSION = "2026.7.1"; +const PATCHED_OPENCLAW_2026_7_1_ISSUE_4434_TUI_ERROR_OUTPUT = [ "run error: LLM request timed out.", "Cause: timed out while reaching the upstream API.", "Reporting layer: gateway proxy / upstream API.", @@ -30,7 +30,7 @@ const PATCHED_OPENCLAW_2026_6_10_ISSUE_4434_TUI_ERROR_OUTPUT = [ "1m 04s | error", ].join("\n"); -const UPSTREAM_OPENCLAW_2026_6_10_ISSUE_4434_TUI_ERROR_OUTPUT = [ +const UPSTREAM_OPENCLAW_2026_7_1_ISSUE_4434_TUI_ERROR_OUTPUT = [ "run error: LLM request timed out.", "1m 04s | error", ].join("\n"); @@ -58,17 +58,17 @@ describe("full OpenClaw TUI error guard (#4434)", () => { it("requires the reviewed patched output to include all full-acceptance fields", () => { expect(readDockerfileOpenClawVersion()).toBe(CURRENT_REVIEWED_OPENCLAW_VERSION); expect( - detectIssue4434AcceptanceFields(PATCHED_OPENCLAW_2026_6_10_ISSUE_4434_TUI_ERROR_OUTPUT), + detectIssue4434AcceptanceFields(PATCHED_OPENCLAW_2026_7_1_ISSUE_4434_TUI_ERROR_OUTPUT), ).toEqual({ httpStatusOrCause: true, reportingLayer: true, recoveryHint: true, }); expect( - missingIssue4434AcceptanceFields(PATCHED_OPENCLAW_2026_6_10_ISSUE_4434_TUI_ERROR_OUTPUT), + missingIssue4434AcceptanceFields(PATCHED_OPENCLAW_2026_7_1_ISSUE_4434_TUI_ERROR_OUTPUT), ).toEqual([]); expect( - missingIssue4434AcceptanceFields(UPSTREAM_OPENCLAW_2026_6_10_ISSUE_4434_TUI_ERROR_OUTPUT), + missingIssue4434AcceptanceFields(UPSTREAM_OPENCLAW_2026_7_1_ISSUE_4434_TUI_ERROR_OUTPUT), ).toEqual(["httpStatusOrCause", "reportingLayer", "recoveryHint"]); }); @@ -82,7 +82,7 @@ describe("full OpenClaw TUI error guard (#4434)", () => { expect(review).not.toContain("`PRA-5`"); expect(review).toContain("3/3 fields are present in the NemoClaw-patched runtime output"); expect(review).toContain( - "3/3 fields are missing in the upstream-shaped `openclaw@2026.6.10` output", + "3/3 fields are missing in the upstream-shaped `openclaw@2026.7.1` output", ); expect(vitestGuard).toContain("../support/issue-4434-tui-capture.ts"); expect(vitestGuard).toContain("finalErrorBlock"); diff --git a/test/messaging-build-applier-integrity.test.ts b/test/messaging-build-applier-integrity.test.ts index 3aab16739b6..b4b0e3467d7 100644 --- a/test/messaging-build-applier-integrity.test.ts +++ b/test/messaging-build-applier-integrity.test.ts @@ -26,10 +26,10 @@ const SCRIPT_PATH = path.join( "build", "messaging-build-applier.mts", ); -const OPENCLAW_SLACK_2026_6_10_INTEGRITY = - "sha512-OOsMLjPcbWhQRM5XDwfdrACjJmKqavFtpuIlhHAXWrLrd/p7SyIVE9AoKS0yxOx6bqGDIMJ9+knzdViHMLgBdA=="; -const OPENCLAW_SLACK_2026_6_10_TARBALL = - "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.6.10.tgz"; +const OPENCLAW_SLACK_2026_7_1_INTEGRITY = + "sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA=="; +const OPENCLAW_SLACK_2026_7_1_TARBALL = + "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz"; const REPO_ROOT = path.join(import.meta.dirname, ".."); function channelsB64(channels: string[]): string { @@ -43,13 +43,13 @@ function fakeSlackNpmScript(): string { 'if [ "${1:-}" = "pack" ]; then', ' pack_dir="${4:-}";', ' test -n "$pack_dir";', - ' reported_filename="${OPENCLAW_PACK_FILENAME_OVERRIDE:-slack-2026.6.10.tgz}";', - ' printf "fake plugin tarball" > "$pack_dir/slack-2026.6.10.tgz";', + ' reported_filename="${OPENCLAW_PACK_FILENAME_OVERRIDE:-slack-2026.7.1.tgz}";', + ' printf "fake plugin tarball" > "$pack_dir/slack-2026.7.1.tgz";', ' printf \'[{"filename":"%s","integrity":"%s"}]\\n\' "$reported_filename" "$OPENCLAW_PACK_INTEGRITY_OVERRIDE";', " exit 0", "fi", 'if [ "${1:-}" = "view" ] && [ "${3:-}" = "dist.integrity" ]; then printf "%s\\n" "$OPENCLAW_SLACK_INTEGRITY"; exit 0; fi', - `if [ "\${1:-}" = "view" ] && [ "\${3:-}" = "dist.tarball" ]; then printf "%s\\n" "\${OPENCLAW_REGISTRY_TARBALL_URL:-${OPENCLAW_SLACK_2026_6_10_TARBALL}}"; exit 0; fi`, + `if [ "\${1:-}" = "view" ] && [ "\${3:-}" = "dist.tarball" ]; then printf "%s\\n" "\${OPENCLAW_REGISTRY_TARBALL_URL:-${OPENCLAW_SLACK_2026_7_1_TARBALL}}"; exit 0; fi`, "exit 1", "", ].join("\n"); @@ -74,7 +74,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { const messagingRoot = path.join(root, "src", "lib", "messaging"); try { for (const copy of dockerfile.matchAll( - /^COPY (src\/lib\/messaging\/|scripts\/lib\/reviewed-npm-archive\.mts) (\/\S+)$/gm, + /^COPY (src\/lib\/messaging\/|scripts\/lib\/(?:openclaw-npm-remediation|reviewed-npm-archive)\.mts) (\/\S+)$/gm, )) { const source = copy[1] ?? ""; const destination = copy[2] ?? ""; @@ -136,9 +136,9 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { { PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, OPENCLAW_TRACE: tracePath, - OPENCLAW_SLACK_INTEGRITY: OPENCLAW_SLACK_2026_6_10_INTEGRITY, - OPENCLAW_PACK_INTEGRITY_OVERRIDE: OPENCLAW_SLACK_2026_6_10_INTEGRITY, - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_SLACK_INTEGRITY: OPENCLAW_SLACK_2026_7_1_INTEGRITY, + OPENCLAW_PACK_INTEGRITY_OVERRIDE: OPENCLAW_SLACK_2026_7_1_INTEGRITY, + OPENCLAW_VERSION: "2026.7.1", NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["slack"]), }, "openclaw", @@ -147,11 +147,11 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { expect(applyMessagingBuildPhase(plan, "agent-install", env)).toEqual([]); const trace = fs.readFileSync(tracePath, "utf-8"); - expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); - expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.tarball"); - expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_6_10_TARBALL}|--pack-destination`); + expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.integrity"); + expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.tarball"); + expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_7_1_TARBALL}|--pack-destination`); expect(trace).toContain("openclaw|plugins|install|npm-pack:"); - expect(trace).toContain("slack-2026.6.10.tgz|"); + expect(trace).toContain("slack-2026.7.1.tgz|"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -160,19 +160,19 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { ); it("pins the registry tarball URL for every trusted built-in messaging plugin", () => { - expect( - reviewedOpenClawPluginTarballUrlByPackageSpec({ OPENCLAW_VERSION: "2026.6.10" }), - ).toEqual({ - "@openclaw/discord@2026.6.10": - "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz", - "@openclaw/msteams@2026.6.10": - "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz", - "@openclaw/slack@2026.6.10": OPENCLAW_SLACK_2026_6_10_TARBALL, - "@openclaw/whatsapp@2026.6.10": - "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.6.10.tgz", - "@tencent-weixin/openclaw-weixin@2.4.3": - "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", - }); + expect(reviewedOpenClawPluginTarballUrlByPackageSpec({ OPENCLAW_VERSION: "2026.7.1" })).toEqual( + { + "@openclaw/discord@2026.7.1": + "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz", + "@openclaw/msteams@2026.7.1": + "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz", + "@openclaw/slack@2026.7.1": OPENCLAW_SLACK_2026_7_1_TARBALL, + "@openclaw/whatsapp@2026.7.1": + "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz", + "@tencent-weixin/openclaw-weixin@2.4.3": + "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz", + }, + ); }); it( @@ -197,11 +197,10 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { { PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, OPENCLAW_TRACE: tracePath, - OPENCLAW_SLACK_INTEGRITY: OPENCLAW_SLACK_2026_6_10_INTEGRITY, - OPENCLAW_PACK_INTEGRITY_OVERRIDE: OPENCLAW_SLACK_2026_6_10_INTEGRITY, - OPENCLAW_REGISTRY_TARBALL_URL: - "https://unexpected.invalid/openclaw/slack-2026.6.10.tgz", - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_SLACK_INTEGRITY: OPENCLAW_SLACK_2026_7_1_INTEGRITY, + OPENCLAW_PACK_INTEGRITY_OVERRIDE: OPENCLAW_SLACK_2026_7_1_INTEGRITY, + OPENCLAW_REGISTRY_TARBALL_URL: "https://unexpected.invalid/openclaw/slack-2026.7.1.tgz", + OPENCLAW_VERSION: "2026.7.1", NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["slack"]), }, "openclaw", @@ -210,15 +209,15 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { const message = thrownMessage(() => applyMessagingBuildPhase(plan, "agent-install", env)); expect(message).toContain( - "OpenClaw plugin @openclaw/slack@2026.6.10 npm tarball URL mismatch", + "OpenClaw plugin @openclaw/slack@2026.7.1 npm tarball URL mismatch", ); - expect(message).toContain(`Expected: ${OPENCLAW_SLACK_2026_6_10_TARBALL}`); + expect(message).toContain(`Expected: ${OPENCLAW_SLACK_2026_7_1_TARBALL}`); expect(message).toContain( - "Actual: https://unexpected.invalid/openclaw/slack-2026.6.10.tgz", + "Actual: https://unexpected.invalid/openclaw/slack-2026.7.1.tgz", ); const trace = fs.readFileSync(tracePath, "utf-8"); - expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); - expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.tarball"); + expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.integrity"); + expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.tarball"); expect(trace).not.toContain("npm|pack|"); expect(trace).not.toContain("openclaw|plugins|install"); } finally { @@ -229,7 +228,7 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { ); it( - "fails closed before installing the 2026.6.10 Slack plugin when the packed archive integrity drifts", + "fails closed before installing the 2026.7.1 Slack plugin when the packed archive integrity drifts", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-slack-pack-")); const tracePath = path.join(tmp, "openclaw.trace"); @@ -250,9 +249,9 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { { PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, OPENCLAW_TRACE: tracePath, - OPENCLAW_SLACK_INTEGRITY: OPENCLAW_SLACK_2026_6_10_INTEGRITY, + OPENCLAW_SLACK_INTEGRITY: OPENCLAW_SLACK_2026_7_1_INTEGRITY, OPENCLAW_PACK_INTEGRITY_OVERRIDE: "sha512-packed-drift", - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_VERSION: "2026.7.1", NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["slack"]), }, "openclaw", @@ -261,13 +260,13 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { const message = thrownMessage(() => applyMessagingBuildPhase(plan, "agent-install", env)); expect(message).toContain( - "OpenClaw plugin @openclaw/slack@2026.6.10 downloaded tarball integrity mismatch", + "OpenClaw plugin @openclaw/slack@2026.7.1 downloaded tarball integrity mismatch", ); - expect(message).toContain(`Expected: ${OPENCLAW_SLACK_2026_6_10_INTEGRITY}`); + expect(message).toContain(`Expected: ${OPENCLAW_SLACK_2026_7_1_INTEGRITY}`); expect(message).toContain("Actual: sha512-packed-drift"); const trace = fs.readFileSync(tracePath, "utf-8"); - expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); - expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_6_10_TARBALL}|--pack-destination`); + expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.integrity"); + expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_7_1_TARBALL}|--pack-destination`); expect(trace).not.toContain("openclaw|plugins|install"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); @@ -298,10 +297,10 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { { PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, OPENCLAW_TRACE: tracePath, - OPENCLAW_SLACK_INTEGRITY: OPENCLAW_SLACK_2026_6_10_INTEGRITY, - OPENCLAW_PACK_INTEGRITY_OVERRIDE: OPENCLAW_SLACK_2026_6_10_INTEGRITY, - OPENCLAW_PACK_FILENAME_OVERRIDE: "../slack-2026.6.10.tgz", - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_SLACK_INTEGRITY: OPENCLAW_SLACK_2026_7_1_INTEGRITY, + OPENCLAW_PACK_INTEGRITY_OVERRIDE: OPENCLAW_SLACK_2026_7_1_INTEGRITY, + OPENCLAW_PACK_FILENAME_OVERRIDE: "../slack-2026.7.1.tgz", + OPENCLAW_VERSION: "2026.7.1", NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["slack"]), }, "openclaw", @@ -326,11 +325,11 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => { expect(result.status).not.toBe(0); expect(result.stderr).toContain( - "npm pack @openclaw/slack@2026.6.10 reported unsafe archive filename: ../slack-2026.6.10.tgz", + "npm pack @openclaw/slack@2026.7.1 reported unsafe archive filename: ../slack-2026.7.1.tgz", ); const trace = fs.readFileSync(tracePath, "utf-8"); - expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); - expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_6_10_TARBALL}|--pack-destination`); + expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.integrity"); + expect(trace).toContain(`npm|pack|${OPENCLAW_SLACK_2026_7_1_TARBALL}|--pack-destination`); expect(trace).not.toContain("openclaw|plugins|install"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 99b0e393aba..1594c868973 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -33,14 +33,14 @@ const GENERATOR_PATH = path.join( "scripts", "generate-openclaw-config.mts", ); -const OPENCLAW_DISCORD_2026_6_10_INTEGRITY = - "sha512-NKp/j00l+rk5PC0Lv/0fOIiiQJ1c/OpG9471zqXUDKQie6pQ1Fi9KUZUouyoTMmfLh/n4S0CkEMqrON40eBKXA=="; -const OPENCLAW_SLACK_2026_6_10_INTEGRITY = - "sha512-OOsMLjPcbWhQRM5XDwfdrACjJmKqavFtpuIlhHAXWrLrd/p7SyIVE9AoKS0yxOx6bqGDIMJ9+knzdViHMLgBdA=="; -const OPENCLAW_WHATSAPP_2026_6_10_INTEGRITY = - "sha512-k/XrRdZY77SHrdaRwJOEB7/JRbjp4yVgGD/ZNyakjTMqo32XRVtwPBUnj7726rW8Kl5yyOMQQLKFiD9MDfhmPQ=="; -const OPENCLAW_MSTEAMS_2026_6_10_INTEGRITY = - "sha512-GjHnCPvjbnI0C7mEFcdT2uKDH4/WwOe2dZBfQiWxBtkE76m6TNG0J9dJjD4mc8/pk8rXSO0cWw+KV9jzWtF9VA=="; +const OPENCLAW_DISCORD_2026_7_1_INTEGRITY = + "sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg=="; +const OPENCLAW_SLACK_2026_7_1_INTEGRITY = + "sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA=="; +const OPENCLAW_WHATSAPP_2026_7_1_INTEGRITY = + "sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ=="; +const OPENCLAW_MSTEAMS_2026_7_1_INTEGRITY = + "sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg=="; const TENCENT_WEIXIN_2_4_3_INTEGRITY = "sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw=="; const TEST_PATH = process.env.PATH || "/usr/bin:/bin"; @@ -49,22 +49,22 @@ function fakeOpenClawPluginNpmPackScriptLines(): string[] { return [ 'if [ "${1:-}" = "view" ] && [ "${3:-}" = "dist.tarball" ]; then', ' case "${2:-}" in', - ' "@openclaw/discord@2026.6.10") printf "%s\\n" "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz"; exit 0 ;;', + ' "@openclaw/discord@2026.7.1") printf "%s\\n" "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz"; exit 0 ;;', ' "@tencent-weixin/openclaw-weixin@2.4.3") printf "%s\\n" "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz"; exit 0 ;;', - ' "@openclaw/slack@2026.6.10") printf "%s\\n" "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.6.10.tgz"; exit 0 ;;', - ' "@openclaw/whatsapp@2026.6.10") printf "%s\\n" "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.6.10.tgz"; exit 0 ;;', - ' "@openclaw/msteams@2026.6.10") printf "%s\\n" "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz"; exit 0 ;;', + ' "@openclaw/slack@2026.7.1") printf "%s\\n" "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz"; exit 0 ;;', + ' "@openclaw/whatsapp@2026.7.1") printf "%s\\n" "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz"; exit 0 ;;', + ' "@openclaw/msteams@2026.7.1") printf "%s\\n" "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz"; exit 0 ;;', " *) exit 1 ;;", " esac", "fi", 'if [ "${1:-}" = "pack" ]; then', ' pack_dir="${4:-}";', ' case "${2:-}" in', - ` "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz") pack_file="discord-2026.6.10.tgz"; pack_integrity="\${OPENCLAW_DISCORD_INTEGRITY:-\${OPENCLAW_DISCORD_2026_6_10_INTEGRITY:-}}" ;;`, + ` "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz") pack_file="discord-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_DISCORD_INTEGRITY:-\${OPENCLAW_DISCORD_2026_7_1_INTEGRITY:-}}" ;;`, ` "https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz") pack_file="openclaw-weixin-2.4.3.tgz"; pack_integrity="\${TENCENT_WEIXIN_2_4_3_INTEGRITY:-}" ;;`, - ` "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.6.10.tgz") pack_file="slack-2026.6.10.tgz"; pack_integrity="\${OPENCLAW_SLACK_INTEGRITY:-\${OPENCLAW_SLACK_2026_6_10_INTEGRITY:-}}" ;;`, - ` "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.6.10.tgz") pack_file="whatsapp-2026.6.10.tgz"; pack_integrity="\${OPENCLAW_WHATSAPP_2026_6_10_INTEGRITY:-}" ;;`, - ` "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz") pack_file="msteams-2026.6.10.tgz"; pack_integrity="\${OPENCLAW_MSTEAMS_2026_6_10_INTEGRITY:-}" ;;`, + ` "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz") pack_file="slack-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_SLACK_INTEGRITY:-\${OPENCLAW_SLACK_2026_7_1_INTEGRITY:-}}" ;;`, + ` "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz") pack_file="whatsapp-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_WHATSAPP_2026_7_1_INTEGRITY:-}" ;;`, + ` "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz") pack_file="msteams-2026.7.1.tgz"; pack_integrity="\${OPENCLAW_MSTEAMS_2026_7_1_INTEGRITY:-}" ;;`, " *) exit 1 ;;", " esac", ' test -n "$pack_dir"; test -n "$pack_integrity";', @@ -555,7 +555,7 @@ describe("messaging-build-applier.mts: agent-install", () => { "#!/bin/sh", 'printf \'npm|%s|%s|%s\\n\' "$1" "$2" "$3" >> "$OPENCLAW_TRACE"', ...fakeOpenClawPluginNpmPackScriptLines(), - 'if [ "${1:-}" = "view" ] && [ "${2:-}" = "@openclaw/discord@2026.6.10" ] && [ "${3:-}" = "dist.integrity" ]; then printf "%s\\n" "$OPENCLAW_DISCORD_2026_6_10_INTEGRITY"; exit 0; fi', + 'if [ "${1:-}" = "view" ] && [ "${2:-}" = "@openclaw/discord@2026.7.1" ] && [ "${3:-}" = "dist.integrity" ]; then printf "%s\\n" "$OPENCLAW_DISCORD_2026_7_1_INTEGRITY"; exit 0; fi', "exit 1", "", ].join("\n"), @@ -580,7 +580,7 @@ describe("messaging-build-applier.mts: agent-install", () => { spec: "npm:@openclaw/discord@{{openclaw.version}}", integrity: "sha512-plan-controlled-pin", integrityByVersion: { - "2026.6.10": "sha512-plan-controlled-version-pin", + "2026.7.1": "sha512-plan-controlled-version-pin", }, pin: false, }, @@ -592,20 +592,20 @@ describe("messaging-build-applier.mts: agent-install", () => { const env = { PATH: tmp + ":" + (process.env.PATH || "/usr/bin:/bin"), OPENCLAW_TRACE: tracePath, - OPENCLAW_DISCORD_2026_6_10_INTEGRITY, - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_DISCORD_2026_7_1_INTEGRITY, + OPENCLAW_VERSION: "2026.7.1", NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(JSON.stringify(plan)).toString("base64"), }; const serializedPlan = readMessagingBuildPlanFromEnv(env, "openclaw"); expect(applyMessagingBuildPhase(serializedPlan, "agent-install", env)).toEqual([]); const trace = fs.readFileSync(tracePath, "utf-8"); - expect(trace).toContain("npm|view|@openclaw/discord@2026.6.10|dist.integrity"); + expect(trace).toContain("npm|view|@openclaw/discord@2026.7.1|dist.integrity"); expect(trace).toContain( - "npm|pack|https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz|--pack-destination", + "npm|pack|https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz|--pack-destination", ); expect(trace).toContain("plugins|install|npm-pack:"); - expect(trace).toContain("discord-2026.6.10.tgz|ignore-scripts=true/true"); + expect(trace).toContain("discord-2026.7.1.tgz|ignore-scripts=true/true"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -623,18 +623,18 @@ describe("messaging-build-applier.mts: agent-install", () => { "const [command, packageSpec, fieldOrFlag, destination] = process.argv.slice(2);", "fs.appendFileSync(process.env.OPENCLAW_TRACE, `npm|${command}|${packageSpec}|${fieldOrFlag || ''}\\n`);", "process.stderr.write('npm notice verbose teams metadata '.repeat(50000));", - "if (command === 'view' && packageSpec === '@openclaw/msteams@2026.6.10' && fieldOrFlag === 'dist.integrity') {", - " process.stdout.write(`${process.env.OPENCLAW_MSTEAMS_2026_6_10_INTEGRITY}\\n`);", + "if (command === 'view' && packageSpec === '@openclaw/msteams@2026.7.1' && fieldOrFlag === 'dist.integrity') {", + " process.stdout.write(`${process.env.OPENCLAW_MSTEAMS_2026_7_1_INTEGRITY}\\n`);", " process.exit(0);", "}", - "if (command === 'view' && packageSpec === '@openclaw/msteams@2026.6.10' && fieldOrFlag === 'dist.tarball') {", - " process.stdout.write('https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz\\n');", + "if (command === 'view' && packageSpec === '@openclaw/msteams@2026.7.1' && fieldOrFlag === 'dist.tarball') {", + " process.stdout.write('https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz\\n');", " process.exit(0);", "}", - "if (command === 'pack' && packageSpec === 'https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz') {", - " const packFile = 'msteams-2026.6.10.tgz';", + "if (command === 'pack' && packageSpec === 'https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz') {", + " const packFile = 'msteams-2026.7.1.tgz';", " fs.writeFileSync(path.join(destination, packFile), 'fake plugin tarball');", - " process.stdout.write(JSON.stringify([{ filename: packFile, integrity: process.env.OPENCLAW_MSTEAMS_2026_6_10_INTEGRITY }]) + '\\n');", + " process.stdout.write(JSON.stringify([{ filename: packFile, integrity: process.env.OPENCLAW_MSTEAMS_2026_7_1_INTEGRITY }]) + '\\n');", " process.exit(0);", "}", "process.exit(1);", @@ -658,8 +658,8 @@ describe("messaging-build-applier.mts: agent-install", () => { { PATH: `${tmp}:${TEST_PATH}`, OPENCLAW_TRACE: tracePath, - OPENCLAW_MSTEAMS_2026_6_10_INTEGRITY, - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_MSTEAMS_2026_7_1_INTEGRITY, + OPENCLAW_VERSION: "2026.7.1", NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["teams"]), NEMOCLAW_TEAMS_CONFIG_B64: teamsConfigB64(), }, @@ -669,13 +669,13 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(applyMessagingBuildPhase(plan, "agent-install", env)).toEqual([]); const trace = fs.readFileSync(tracePath, "utf-8"); - expect(trace).toContain("npm|view|@openclaw/msteams@2026.6.10|dist.integrity"); - expect(trace).toContain("npm|view|@openclaw/msteams@2026.6.10|dist.tarball"); + expect(trace).toContain("npm|view|@openclaw/msteams@2026.7.1|dist.integrity"); + expect(trace).toContain("npm|view|@openclaw/msteams@2026.7.1|dist.tarball"); expect(trace).toContain( - "npm|pack|https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz|--pack-destination", + "npm|pack|https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz|--pack-destination", ); expect(trace).toContain("openclaw|plugins|install|npm-pack:"); - expect(trace).toContain("msteams-2026.6.10.tgz|"); + expect(trace).toContain("msteams-2026.7.1.tgz|"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -722,13 +722,13 @@ describe("messaging-build-applier.mts: agent-install", () => { const env = { PATH: tmp + ":" + TEST_PATH, OPENCLAW_TRACE: tracePath, - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_VERSION: "2026.7.1", NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(JSON.stringify(plan)).toString("base64"), }; const serializedPlan = readMessagingBuildPlanFromEnv(env, "openclaw"); expect(() => applyMessagingBuildPhase(serializedPlan, "agent-install", env)).toThrow( - "Messaging package-install output openclawPluginPackage is not declared by a trusted built-in manifest for active OpenClaw channels: npm:@openclaw/slack@2026.6.10", + "Messaging package-install output openclawPluginPackage is not declared by a trusted built-in manifest for active OpenClaw channels: npm:@openclaw/slack@2026.7.1", ); expect(fs.existsSync(tracePath)).toBe(false); } finally { @@ -816,11 +816,11 @@ describe("messaging-build-applier.mts: agent-install", () => { ...fakeOpenClawPluginNpmPackScriptLines(), 'if [ "${1:-}" != "view" ] || [ "${3:-}" != "dist.integrity" ]; then exit 1; fi', 'case "${2:-}" in', - ` "@openclaw/discord@2026.6.10") printf "%s\\n" "${OPENCLAW_DISCORD_2026_6_10_INTEGRITY}"; exit 0 ;;`, + ` "@openclaw/discord@2026.7.1") printf "%s\\n" "${OPENCLAW_DISCORD_2026_7_1_INTEGRITY}"; exit 0 ;;`, ` "@tencent-weixin/openclaw-weixin@2.4.3") printf "%s\\n" "${TENCENT_WEIXIN_2_4_3_INTEGRITY}"; exit 0 ;;`, - ` "@openclaw/slack@2026.6.10") printf "%s\\n" "${OPENCLAW_SLACK_2026_6_10_INTEGRITY}"; exit 0 ;;`, - ` "@openclaw/whatsapp@2026.6.10") printf "%s\\n" "${OPENCLAW_WHATSAPP_2026_6_10_INTEGRITY}"; exit 0 ;;`, - ` "@openclaw/msteams@2026.6.10") printf "%s\\n" "${OPENCLAW_MSTEAMS_2026_6_10_INTEGRITY}"; exit 0 ;;`, + ` "@openclaw/slack@2026.7.1") printf "%s\\n" "${OPENCLAW_SLACK_2026_7_1_INTEGRITY}"; exit 0 ;;`, + ` "@openclaw/whatsapp@2026.7.1") printf "%s\\n" "${OPENCLAW_WHATSAPP_2026_7_1_INTEGRITY}"; exit 0 ;;`, + ` "@openclaw/msteams@2026.7.1") printf "%s\\n" "${OPENCLAW_MSTEAMS_2026_7_1_INTEGRITY}"; exit 0 ;;`, "esac", "exit 1", "", @@ -843,13 +843,13 @@ describe("messaging-build-applier.mts: agent-install", () => { { PATH: `${tmp}:${TEST_PATH}`, OPENCLAW_TRACE: tracePath, - OPENCLAW_DISCORD_2026_6_10_INTEGRITY, - OPENCLAW_SLACK_2026_6_10_INTEGRITY, - OPENCLAW_WHATSAPP_2026_6_10_INTEGRITY, - OPENCLAW_MSTEAMS_2026_6_10_INTEGRITY, + OPENCLAW_DISCORD_2026_7_1_INTEGRITY, + OPENCLAW_SLACK_2026_7_1_INTEGRITY, + OPENCLAW_WHATSAPP_2026_7_1_INTEGRITY, + OPENCLAW_MSTEAMS_2026_7_1_INTEGRITY, TENCENT_WEIXIN_2_4_3_INTEGRITY, NEMOCLAW_WECHAT_NPM_INSTALL_CACHE: tmp, - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_VERSION: "2026.7.1", NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64([ "telegram", "discord", @@ -869,9 +869,9 @@ describe("messaging-build-applier.mts: agent-install", () => { const trace = fs.readFileSync(tracePath, "utf-8"); for (const [packageSpec, tarballUrl, archiveName] of [ [ - "@openclaw/discord@2026.6.10", - "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz", - "discord-2026.6.10.tgz", + "@openclaw/discord@2026.7.1", + "https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz", + "discord-2026.7.1.tgz", ], [ "@tencent-weixin/openclaw-weixin@2.4.3", @@ -879,19 +879,19 @@ describe("messaging-build-applier.mts: agent-install", () => { "openclaw-weixin-2.4.3.tgz", ], [ - "@openclaw/slack@2026.6.10", - "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.6.10.tgz", - "slack-2026.6.10.tgz", + "@openclaw/slack@2026.7.1", + "https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz", + "slack-2026.7.1.tgz", ], [ - "@openclaw/whatsapp@2026.6.10", - "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.6.10.tgz", - "whatsapp-2026.6.10.tgz", + "@openclaw/whatsapp@2026.7.1", + "https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz", + "whatsapp-2026.7.1.tgz", ], [ - "@openclaw/msteams@2026.6.10", - "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz", - "msteams-2026.6.10.tgz", + "@openclaw/msteams@2026.7.1", + "https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz", + "msteams-2026.7.1.tgz", ], ] as const) { expect(trace).toContain(`npm|view|${packageSpec}|dist.integrity`); @@ -910,7 +910,7 @@ describe("messaging-build-applier.mts: agent-install", () => { testTimeout(15_000), ); - it("verifies reviewed npm integrity before installing the 2026.6.10 Slack plugin", async () => { + it("verifies reviewed npm integrity before installing the 2026.7.1 Slack plugin", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-slack-integrity-")); const tracePath = path.join(tmp, "openclaw.trace"); fs.writeFileSync( @@ -941,8 +941,8 @@ describe("messaging-build-applier.mts: agent-install", () => { { PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, OPENCLAW_TRACE: tracePath, - OPENCLAW_SLACK_INTEGRITY: OPENCLAW_SLACK_2026_6_10_INTEGRITY, - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_SLACK_INTEGRITY: OPENCLAW_SLACK_2026_7_1_INTEGRITY, + OPENCLAW_VERSION: "2026.7.1", NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["slack"]), }, "openclaw", @@ -951,19 +951,19 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(applyMessagingBuildPhase(plan, "agent-install", env)).toEqual([]); const trace = fs.readFileSync(tracePath, "utf-8"); - expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.integrity"); - expect(trace).toContain("npm|view|@openclaw/slack@2026.6.10|dist.tarball"); + expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.integrity"); + expect(trace).toContain("npm|view|@openclaw/slack@2026.7.1|dist.tarball"); expect(trace).toContain( - "npm|pack|https://registry.npmjs.org/@openclaw/slack/-/slack-2026.6.10.tgz|--pack-destination", + "npm|pack|https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz|--pack-destination", ); expect(trace).toContain("openclaw|plugins|install|npm-pack:"); - expect(trace).toContain("slack-2026.6.10.tgz|"); + expect(trace).toContain("slack-2026.7.1.tgz|"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); - it("fails closed before installing the 2026.6.10 Slack plugin when registry integrity drifts", async () => { + it("fails closed before installing the 2026.7.1 Slack plugin when registry integrity drifts", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-slack-integrity-")); const tracePath = path.join(tmp, "openclaw.trace"); fs.writeFileSync( @@ -993,7 +993,7 @@ describe("messaging-build-applier.mts: agent-install", () => { { PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, OPENCLAW_TRACE: tracePath, - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_VERSION: "2026.7.1", NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["slack"]), }, "openclaw", @@ -1001,11 +1001,11 @@ describe("messaging-build-applier.mts: agent-install", () => { const plan = readMessagingBuildPlanFromEnv(env, "openclaw"); const message = thrownMessage(() => applyMessagingBuildPhase(plan, "agent-install", env)); - expect(message).toContain("OpenClaw plugin @openclaw/slack@2026.6.10 npm integrity mismatch"); - expect(message).toContain(`Expected: ${OPENCLAW_SLACK_2026_6_10_INTEGRITY}`); + expect(message).toContain("OpenClaw plugin @openclaw/slack@2026.7.1 npm integrity mismatch"); + expect(message).toContain(`Expected: ${OPENCLAW_SLACK_2026_7_1_INTEGRITY}`); expect(message).toContain("Actual: sha512-drift"); expect(fs.readFileSync(tracePath, "utf-8").trim()).toBe( - "npm|view|@openclaw/slack@2026.6.10|dist.integrity", + "npm|view|@openclaw/slack@2026.7.1|dist.integrity", ); } finally { fs.rmSync(tmp, { recursive: true, force: true }); @@ -1102,7 +1102,7 @@ describe("messaging-build-applier.mts: agent-install", () => { "const args = process.argv.slice(2);", 'fs.appendFileSync(process.env.OPENCLAW_TRACE, `${args.join("|")}|${process.env.DISCORD_BOT_TOKEN || ""}|${process.env.BRAVE_API_KEY || ""}\\n`);', 'if (args[0] === "plugins" && args[1] === "install") {', - ' if (!args[2].startsWith("npm-pack:") || !args[2].endsWith("discord-2026.6.10.tgz")) process.exit(41);', + ' if (!args[2].startsWith("npm-pack:") || !args[2].endsWith("discord-2026.7.1.tgz")) process.exit(41);', " if (args.length !== 3) process.exit(47);", " process.exit(0);", "}", @@ -1125,7 +1125,7 @@ describe("messaging-build-applier.mts: agent-install", () => { "#!/bin/sh", 'printf \'npm|%s|%s|%s||\\n\' "$1" "$2" "$3" >> "$OPENCLAW_TRACE"', ...fakeOpenClawPluginNpmPackScriptLines(), - 'if [ "${1:-}" = "view" ] && [ "${2:-}" = "@openclaw/discord@2026.6.10" ] && [ "${3:-}" = "dist.integrity" ]; then printf "%s\\n" "$OPENCLAW_DISCORD_2026_6_10_INTEGRITY"; exit 0; fi', + 'if [ "${1:-}" = "view" ] && [ "${2:-}" = "@openclaw/discord@2026.7.1" ] && [ "${3:-}" = "dist.integrity" ]; then printf "%s\\n" "$OPENCLAW_DISCORD_2026_7_1_INTEGRITY"; exit 0; fi', "exit 1", "", ].join("\n"), @@ -1156,8 +1156,8 @@ describe("messaging-build-applier.mts: agent-install", () => { PATH: `${tmp}:${TEST_PATH}`, HOME: tmp, OPENCLAW_TRACE: tracePath, - OPENCLAW_DISCORD_2026_6_10_INTEGRITY, - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_DISCORD_2026_7_1_INTEGRITY, + OPENCLAW_VERSION: "2026.7.1", NEMOCLAW_MESSAGING_PLAN_B64: generatorEnv.NEMOCLAW_MESSAGING_PLAN_B64, NEMOCLAW_WEB_SEARCH_ENABLED: "1", }; @@ -1168,12 +1168,12 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(postInstallResult.status, postInstallResult.stderr).toBe(0); const trace = fs.readFileSync(tracePath, "utf-8"); - expect(trace).toContain("npm|view|@openclaw/discord@2026.6.10|dist.integrity||"); + expect(trace).toContain("npm|view|@openclaw/discord@2026.7.1|dist.integrity||"); expect(trace).toContain( - "npm|pack|https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz|--pack-destination||", + "npm|pack|https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz|--pack-destination||", ); expect(trace).toContain("plugins|install|npm-pack:"); - expect(trace).toContain("discord-2026.6.10.tgz||"); + expect(trace).toContain("discord-2026.7.1.tgz||"); expect(trace).toContain( "doctor|--fix|--non-interactive|openshell:resolve:env:DISCORD_BOT_TOKEN|openshell:resolve:env:BRAVE_API_KEY", ); diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index a5f6a400ca4..dde2e16a043 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -18,7 +18,7 @@ const DEPENDENCY_REVIEW = path.join( ); const CODEX_ACP_TARBALL = "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.11.1.tgz"; -const OPENCLAW_TARBALL = "https://registry.npmjs.org/openclaw/-/openclaw-2026.6.10.tgz"; +const OPENCLAW_TARBALL = "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz"; const MESSAGING_BUILD_APPLIER = path.join( REPO_ROOT, "src", @@ -355,8 +355,8 @@ for dockerfile in Dockerfile Dockerfile.base; do Dockerfile) end_marker='# Patch OpenClaw media fetch' ;; Dockerfile.base) end_marker='# Baseline health check.' ;; esac - openclaw_block="$(sed -n "/ARG OPENCLAW_VERSION=2026.6.10/,/$end_marker/p" "$dockerfile")" - check_contains "$openclaw_block" "ARG OPENCLAW_2026_6_10_TARBALL=${OPENCLAW_TARBALL}" "$dockerfile tarball arg" + openclaw_block="$(sed -n "/ARG OPENCLAW_VERSION=2026.7.1/,/$end_marker/p" "$dockerfile")" + check_contains "$openclaw_block" "ARG OPENCLAW_2026_7_1_TARBALL=${OPENCLAW_TARBALL}" "$dockerfile tarball arg" check_contains "$openclaw_block" '/scripts/lib/reviewed-npm-archive.mts' "$dockerfile shared helper" check_contains "$openclaw_block" '--package-spec "openclaw@\${OPENCLAW_VERSION}" --integrity "$EXPECTED_INTEGRITY"' "$dockerfile reviewed identity" check_contains "$openclaw_block" '--tarball-url "$EXPECTED_TARBALL"' "$dockerfile reviewed tarball" diff --git a/test/openclaw-device-self-approval-patch.test.ts b/test/openclaw-device-self-approval-patch.test.ts index 5b354b9e62d..5a319567546 100644 --- a/test/openclaw-device-self-approval-patch.test.ts +++ b/test/openclaw-device-self-approval-patch.test.ts @@ -150,7 +150,7 @@ function selfApprovalOptions() { } describe("OpenClaw bounded device self-approval patch (#4462)", () => { - it("applies and audits exactly one CLI, gateway, and canonical-state target", () => { + it("applies and audits exactly one CLI, gateway-auth, gateway-handler, and canonical-state target", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-self-approval-")); const dist = path.join(tmp, "dist"); fs.mkdirSync(dist); @@ -158,14 +158,14 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { try { const freshAudit = runPatch(dist, true); expect(freshAudit.status, `${freshAudit.stdout}${freshAudit.stderr}`).toBe(0); - expect(freshAudit.stdout).toContain("3 OK · 0 missing"); + expect(freshAudit.stdout).toContain("4 OK · 0 missing"); expect(freshAudit.stdout).toContain("would-apply"); const apply = runPatch(dist); expect(apply.status, `${apply.stdout}${apply.stderr}`).toBe(0); const appliedAudit = runPatch(dist, true); expect(appliedAudit.status, `${appliedAudit.stdout}${appliedAudit.stderr}`).toBe(0); - expect(appliedAudit.stdout.match(/already-applied/gu)).toHaveLength(3); + expect(appliedAudit.stdout.match(/already-applied/gu)).toHaveLength(4); const secondApply = runPatch(dist); expect(secondApply.status, `${secondApply.stdout}${secondApply.stderr}`).toBe(0); @@ -174,6 +174,51 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { } }); + it("routes only a bounded CLI device-token scope mismatch into canonical pairing", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-auth-upgrade-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist); + writeFixtureDist(dist); + try { + expect(runPatch(dist).status).toBe(0); + const source = fs.readFileSync(path.join(dist, "message-handler-fixture.js"), "utf8"); + const connect = runFixture< + ( + params: Record, + verify: () => Promise>, + ) => Promise> + >(source, "connect"); + const scopeMismatch = async () => ({ ok: false, reason: "scope_mismatch" }); + + await expect( + connect( + { client: { id: "cli", mode: "cli" }, role: "operator", scopes: ["operator.write"] }, + scopeMismatch, + ), + ).resolves.toMatchObject({ authOk: true, authMethod: "device-token" }); + for (const candidate of [ + { client: { id: "control-ui", mode: "ui" }, role: "operator", scopes: ["operator.write"] }, + { client: { id: "cli", mode: "cli" }, role: "node", scopes: ["operator.write"] }, + { client: { id: "cli", mode: "cli" }, role: "operator", scopes: ["operator.admin"] }, + { + client: { id: "cli", mode: "cli" }, + role: "operator", + scopes: ["operator.write", "operator.write"], + }, + ]) { + await expect(connect(candidate, scopeMismatch)).resolves.toMatchObject({ authOk: false }); + } + await expect( + connect( + { client: { id: "cli", mode: "cli" }, role: "operator", scopes: ["operator.write"] }, + async () => ({ ok: false, reason: "token-mismatch" }), + ), + ).resolves.toMatchObject({ authOk: false }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("uses only operator.pairing to reach the gateway for the exact complete CLI shape", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-cli-scope-")); const dist = path.join(tmp, "dist"); diff --git a/test/openclaw-integrity-pin-suite.ts b/test/openclaw-integrity-pin-suite.ts index fe3b997ffb1..8e387739e6f 100644 --- a/test/openclaw-integrity-pin-suite.ts +++ b/test/openclaw-integrity-pin-suite.ts @@ -26,7 +26,7 @@ const DEPENDENCY_REVIEW_NOTE = path.join( REPO_ROOT, "docs", "security", - "openclaw-2026.6.10-dependency-review.md", + "openclaw-2026.7.1-dependency-review.md", ); const PRODUCTION_BUILD_ARG_GUARD = path.join( REPO_ROOT, @@ -40,10 +40,10 @@ const REVIEWED_NPM_ARCHIVE_HELPER = path.join( "reviewed-npm-archive.mts", ); const UNPINNED_OPENCLAW_VERSION = "2026.6.11"; -const PINNED_OPENCLAW_VERSION = "2026.6.10"; +const PINNED_OPENCLAW_VERSION = "2026.7.1"; const PINNED_OPENCLAW_INTEGRITY = - "sha512-LcooND2tBQw8A+kc1Ujltu3lg30bJ0w7XaeRy7eYzobb8BBdcW6DOGbwJL4vpj1vl9+gjRceOtlh5nh9OARcug=="; -const PINNED_OPENCLAW_TARBALL = "https://registry.npmjs.org/openclaw/-/openclaw-2026.6.10.tgz"; + "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g=="; +const PINNED_OPENCLAW_TARBALL = "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz"; const PINNED_CODEX_ACP_VERSION = "0.11.1"; const PINNED_CODEX_ACP_TARBALL = "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.11.1.tgz"; @@ -64,21 +64,21 @@ const PINNED_MCPORTER_LOCK_SHA256 = createHash("sha256") .update(fs.readFileSync(MCPORTER_LOCKFILE)) .digest("hex"); const PINNED_OPENCLAW_DIAGNOSTICS_OTEL_INTEGRITY = - "sha512-EJt0fjk4bcR3N/9u00f1pL0BJYG5yfC09DV3l6rWDmytpE2vUeBZWpx4pOmFDreGV+7DKxhCbQDgDAmvZGjLag=="; + "sha512-XXhMifYWTgoR6yFN4T3JkHxdPvQCe8k1cNZjVIgXNmk1svCdBWuALfQQicmpemlmWwauIQuHYgBURY6k63e+rw=="; const PINNED_OPENCLAW_DIAGNOSTICS_OTEL_TARBALL = - "https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.6.10.tgz"; + "https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.7.1.tgz"; const PINNED_OPENCLAW_BRAVE_PLUGIN_INTEGRITY = - "sha512-DDRnb4reL99O8kbISNbRFyk/xoUPYHsXG3UGikKAsVs+zIldYYA0hY0d3Z2aWoE+0vfda27mJUByCo7Xr15qdw=="; + "sha512-7Z+GZ/6K6a8LlkTsWVnAZ1hv8EarORzHQvFHD7ekcg033FGJOXYPEZSbvvE3qR9vM+vnoZplNjMZ7vFMRcvQgw=="; const PINNED_OPENCLAW_BRAVE_PLUGIN_TARBALL = - "https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.10.tgz"; + "https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.7.1.tgz"; const PINNED_OPENCLAW_DISCORD_INTEGRITY = - "sha512-NKp/j00l+rk5PC0Lv/0fOIiiQJ1c/OpG9471zqXUDKQie6pQ1Fi9KUZUouyoTMmfLh/n4S0CkEMqrON40eBKXA=="; + "sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg=="; const PINNED_OPENCLAW_SLACK_INTEGRITY = - "sha512-OOsMLjPcbWhQRM5XDwfdrACjJmKqavFtpuIlhHAXWrLrd/p7SyIVE9AoKS0yxOx6bqGDIMJ9+knzdViHMLgBdA=="; + "sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA=="; const PINNED_OPENCLAW_WHATSAPP_INTEGRITY = - "sha512-k/XrRdZY77SHrdaRwJOEB7/JRbjp4yVgGD/ZNyakjTMqo32XRVtwPBUnj7726rW8Kl5yyOMQQLKFiD9MDfhmPQ=="; + "sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ=="; const PINNED_OPENCLAW_MSTEAMS_INTEGRITY = - "sha512-GjHnCPvjbnI0C7mEFcdT2uKDH4/WwOe2dZBfQiWxBtkE76m6TNG0J9dJjD4mc8/pk8rXSO0cWw+KV9jzWtF9VA=="; + "sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg=="; const PINNED_WECHAT_PLUGIN_INTEGRITY = "sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw=="; const LEGACY_REBUILD_OPENCLAW_VERSION = "2026.3.11"; @@ -242,8 +242,8 @@ function runInstallBlock( `BASE_IMAGE=${JSON.stringify(baseImage)}`, `openclaw_provenance_path=${JSON.stringify(provenancePath)}`, `openclaw_provenance_metadata=${JSON.stringify(baseProvenanceMetadata)}`, - `OPENCLAW_2026_6_10_INTEGRITY=${JSON.stringify(committedIntegrity)}`, - `OPENCLAW_2026_6_10_TARBALL=${JSON.stringify(PINNED_OPENCLAW_TARBALL)}`, + `OPENCLAW_2026_7_1_INTEGRITY=${JSON.stringify(committedIntegrity)}`, + `OPENCLAW_2026_7_1_TARBALL=${JSON.stringify(PINNED_OPENCLAW_TARBALL)}`, `NEMOCLAW_E2E_FIXTURE_LEGACY_OPENCLAW=${allowLegacyFixture ? "1" : "0"}`, `OPENCLAW_2026_3_11_INTEGRITY=${JSON.stringify(LEGACY_REBUILD_OPENCLAW_INTEGRITY)}`, `OPENCLAW_2026_3_11_TARBALL=${JSON.stringify(LEGACY_REBUILD_OPENCLAW_TARBALL)}`, @@ -406,8 +406,8 @@ function runOptionalOpenClawPluginBlock( "set -euo pipefail", `call_log=${JSON.stringify(log)}`, `OPENCLAW_VERSION=${JSON.stringify(openclawVersion)}`, - `OPENCLAW_DIAGNOSTICS_OTEL_2026_6_10_INTEGRITY=${JSON.stringify(PINNED_OPENCLAW_DIAGNOSTICS_OTEL_INTEGRITY)}`, - `OPENCLAW_BRAVE_PLUGIN_2026_6_10_INTEGRITY=${JSON.stringify(PINNED_OPENCLAW_BRAVE_PLUGIN_INTEGRITY)}`, + `OPENCLAW_DIAGNOSTICS_OTEL_2026_7_1_INTEGRITY=${JSON.stringify(PINNED_OPENCLAW_DIAGNOSTICS_OTEL_INTEGRITY)}`, + `OPENCLAW_BRAVE_PLUGIN_2026_7_1_INTEGRITY=${JSON.stringify(PINNED_OPENCLAW_BRAVE_PLUGIN_INTEGRITY)}`, `NEMOCLAW_OPENCLAW_OTEL=${otel ? "1" : "0"}`, `NEMOCLAW_WEB_SEARCH_ENABLED=${webSearch ? "1" : "0"}`, `export NEMOCLAW_REVIEWED_NPM_EXECUTABLE=${JSON.stringify(reviewedNpmExecutable)}`, @@ -462,17 +462,17 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(reviewNote).toContain(`@zed-industries/codex-acp@${PINNED_CODEX_ACP_VERSION}`); expect(reviewNote).toContain(PINNED_CODEX_ACP_TARBALL); expect(reviewNote).toContain(PINNED_CODEX_ACP_INTEGRITY); - expect(reviewNote).toContain("@openclaw/diagnostics-otel@2026.6.10"); + expect(reviewNote).toContain("@openclaw/diagnostics-otel@2026.7.1"); expect(reviewNote).toContain(PINNED_OPENCLAW_DIAGNOSTICS_OTEL_INTEGRITY); - expect(reviewNote).toContain("@openclaw/brave-plugin@2026.6.10"); + expect(reviewNote).toContain("@openclaw/brave-plugin@2026.7.1"); expect(reviewNote).toContain(PINNED_OPENCLAW_BRAVE_PLUGIN_INTEGRITY); - expect(reviewNote).toContain("@openclaw/discord@2026.6.10"); + expect(reviewNote).toContain("@openclaw/discord@2026.7.1"); expect(reviewNote).toContain(PINNED_OPENCLAW_DISCORD_INTEGRITY); - expect(reviewNote).toContain("@openclaw/slack@2026.6.10"); + expect(reviewNote).toContain("@openclaw/slack@2026.7.1"); expect(reviewNote).toContain(PINNED_OPENCLAW_SLACK_INTEGRITY); - expect(reviewNote).toContain("@openclaw/whatsapp@2026.6.10"); + expect(reviewNote).toContain("@openclaw/whatsapp@2026.7.1"); expect(reviewNote).toContain(PINNED_OPENCLAW_WHATSAPP_INTEGRITY); - expect(reviewNote).toContain("@openclaw/msteams@2026.6.10"); + expect(reviewNote).toContain("@openclaw/msteams@2026.7.1"); expect(reviewNote).toContain(PINNED_OPENCLAW_MSTEAMS_INTEGRITY); expect(reviewNote).toContain("@tencent-weixin/openclaw-weixin@2.4.3"); expect(reviewNote).toContain(PINNED_WECHAT_PLUGIN_INTEGRITY); @@ -494,12 +494,12 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(reviewNote).toContain("openclaw-diagnostics-otel-local"); expect(reviewNote).toContain("imports `OTLPTraceExporter`"); expect(reviewNote).toContain("contains no `web_fetch`, `fetchWithSsrFGuard`"); - expect(reviewNote).toContain("@openclaw/diagnostics-otel@2026.6.10"); - expect(reviewNote).toContain("@openclaw/brave-plugin@2026.6.10"); + expect(reviewNote).toContain("@openclaw/diagnostics-otel@2026.7.1"); + expect(reviewNote).toContain("@openclaw/brave-plugin@2026.7.1"); expect(reviewNote).toContain("@tencent-weixin/openclaw-weixin@2.4.3"); - expect(reviewNote).toContain("`0` high"); + expect(reviewNote).toContain("`1` high"); expect(reviewNote).toContain("`0` critical"); - expect(reviewNote).toContain("`763` total dependencies"); + expect(reviewNote).toContain("`818` total dependencies"); expect(reviewNote).toContain( "`dist/pipeline.runtime-*.js`, which exports `prepareSlackMessage`", ); @@ -527,7 +527,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(reviewNote).toContain("default 180-second timeout"); }); - it("keeps the Teams OpenClaw plugin manifest pinned to the reviewed 2026.6.10 integrity", () => { + it("keeps the Teams OpenClaw plugin manifest pinned to the reviewed 2026.7.1 integrity", () => { const teamsManifest = createBuiltInChannelManifestRegistry().get("teams"); const teamsPackage = teamsManifest?.agentPackages?.find( (agentPackage) => @@ -583,7 +583,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes it.each([ "latest", - "^2026.6.10", + "^2026.7.1", ])("rejects a trusted OpenClaw plugin manifest with non-exact version %s", (version) => { const slackManifest = createBuiltInChannelManifestRegistry().get("slack"); expect(slackManifest).toBeDefined(); @@ -624,7 +624,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes `npm view @openclaw/diagnostics-otel@${PINNED_OPENCLAW_VERSION} dist.tarball`, ); expect(calls).toContain( - "npm pack https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.6.10.tgz --pack-destination", + "npm pack https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.7.1.tgz --pack-destination", ); expect(calls).toMatch( /openclaw plugins install npm-pack:\S*\/diagnostics-otel-2026\.6\.10\.tgz\n/, @@ -636,7 +636,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes `npm view @openclaw/brave-plugin@${PINNED_OPENCLAW_VERSION} dist.tarball`, ); expect(calls).toContain( - "npm pack https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.10.tgz --pack-destination", + "npm pack https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.7.1.tgz --pack-destination", ); expect(calls).toMatch( /openclaw plugins install npm-pack:\S*\/brave-plugin-2026\.6\.10\.tgz\n/, @@ -1000,7 +1000,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes openclawVersion: PINNED_OPENCLAW_VERSION, committedIntegrity: PINNED_OPENCLAW_INTEGRITY, registryIntegrity: PINNED_OPENCLAW_INTEGRITY, - packFilename: "../openclaw-2026.6.10.tgz", + packFilename: "../openclaw-2026.7.1.tgz", }, ); const codexAcp = runInstallBlock( @@ -1026,18 +1026,18 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes openclawVersion: PINNED_OPENCLAW_VERSION, committedIntegrity: PINNED_OPENCLAW_INTEGRITY, registryIntegrity: PINNED_OPENCLAW_INTEGRITY, - packFilename: "../openclaw-2026.6.10.tgz", + packFilename: "../openclaw-2026.7.1.tgz", }, ); const optionalPlugin = runOptionalOpenClawPluginBlock({ - pluginPackFilename: "../diagnostics-otel-2026.6.10.tgz", + pluginPackFilename: "../diagnostics-otel-2026.7.1.tgz", }); for (const item of [ { label: "production Dockerfile", outcome: production, - unsafeFilename: "../openclaw-2026.6.10.tgz", + unsafeFilename: "../openclaw-2026.7.1.tgz", blockedCommand: "npm install -g", }, { @@ -1049,13 +1049,13 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes { label: "base Dockerfile", outcome: base, - unsafeFilename: "../openclaw-2026.6.10.tgz", + unsafeFilename: "../openclaw-2026.7.1.tgz", blockedCommand: "npm install -g", }, { label: "optional OpenClaw plugin Dockerfile", outcome: optionalPlugin, - unsafeFilename: "../diagnostics-otel-2026.6.10.tgz", + unsafeFilename: "../diagnostics-otel-2026.7.1.tgz", blockedCommand: "openclaw plugins install", }, ]) { @@ -1257,10 +1257,10 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes "OPENCLAW_2026_3_11_TARBALL", "OPENCLAW_2026_4_24_INTEGRITY", "OPENCLAW_2026_4_24_TARBALL", - "OPENCLAW_2026_6_10_INTEGRITY", - "OPENCLAW_2026_6_10_TARBALL", - "OPENCLAW_BRAVE_PLUGIN_2026_6_10_INTEGRITY", - "OPENCLAW_DIAGNOSTICS_OTEL_2026_6_10_INTEGRITY", + "OPENCLAW_2026_7_1_INTEGRITY", + "OPENCLAW_2026_7_1_TARBALL", + "OPENCLAW_BRAVE_PLUGIN_2026_7_1_INTEGRITY", + "OPENCLAW_DIAGNOSTICS_OTEL_2026_7_1_INTEGRITY", ]); const futurePinArgNames = [ diff --git a/test/openclaw-lifecycle-policy.test.ts b/test/openclaw-lifecycle-policy.test.ts index bbcc1d5df06..b07d9c45ee4 100644 --- a/test/openclaw-lifecycle-policy.test.ts +++ b/test/openclaw-lifecycle-policy.test.ts @@ -113,7 +113,7 @@ describe("reviewed npm lifecycle policy", () => { ).toBe(true); const messagingPackageSpecs = Object.keys( - reviewedOpenClawPluginIntegrityByPackageSpec({ OPENCLAW_VERSION: "2026.6.10" }), + reviewedOpenClawPluginIntegrityByPackageSpec({ OPENCLAW_VERSION: "2026.7.1" }), ); const result = spawnSync(process.execPath, ["-e", PRODUCTION_BOUNDARY_AUDIT], { cwd: REPO_ROOT, diff --git a/test/openclaw-npm-remediation.test.ts b/test/openclaw-npm-remediation.test.ts new file mode 100644 index 00000000000..ff30ff04fca --- /dev/null +++ b/test/openclaw-npm-remediation.test.ts @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { patchOpenClawPluginPackageGraph } from "../scripts/lib/openclaw-npm-remediation.mts"; + +const temporaryDirectories: string[] = []; + +function writeFixture(axiosVersion = "1.16.0"): string { + const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-npm-remediation-")); + temporaryDirectories.push(directory); + writeFileSync( + path.join(directory, "package.json"), + `${JSON.stringify( + { + name: "@openclaw/slack", + version: "2026.7.1", + dependencies: { "@slack/bolt": "4.7.3" }, + bundledDependencies: ["@slack/bolt"], + }, + null, + 2, + )}\n`, + ); + writeFileSync( + path.join(directory, "npm-shrinkwrap.json"), + `${JSON.stringify( + { + name: "@openclaw/slack", + version: "2026.7.1", + lockfileVersion: 3, + requires: true, + packages: { + "": { + name: "@openclaw/slack", + version: "2026.7.1", + dependencies: { "@slack/bolt": "4.7.3" }, + }, + "node_modules/axios": { + version: axiosVersion, + resolved: `https://registry.npmjs.org/axios/-/axios-${axiosVersion}.tgz`, + integrity: "sha512-old", + dependencies: { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0", + }, + }, + }, + }, + null, + 2, + )}\n`, + ); + return directory; +} + +function readJson(file: string): T { + return JSON.parse(readFileSync(file, "utf-8")) as T; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("OpenClaw npm remediation", () => { + it("replaces the reviewed bundled Axios graph with the patched graph", () => { + const directory = writeFixture(); + + patchOpenClawPluginPackageGraph(directory, "@openclaw/slack@2026.7.1"); + + const shrinkwrap = readJson<{ + packages: Record }>; + }>(path.join(directory, "npm-shrinkwrap.json")); + expect(shrinkwrap.packages["node_modules/axios"]).toMatchObject({ + version: "1.18.0", + dependencies: { "https-proxy-agent": "^5.0.1" }, + }); + expect(shrinkwrap.packages["node_modules/axios/node_modules/https-proxy-agent"]).toMatchObject({ + version: "5.0.1", + dependencies: { "agent-base": "6" }, + }); + expect( + shrinkwrap.packages[ + "node_modules/axios/node_modules/https-proxy-agent/node_modules/agent-base" + ], + ).toMatchObject({ version: "6.0.2", dependencies: { debug: "4" } }); + }); + + it("rejects an upstream Axios graph that changed after review", () => { + const directory = writeFixture("1.17.0"); + + expect(() => patchOpenClawPluginPackageGraph(directory, "@openclaw/slack@2026.7.1")).toThrow( + "must resolve node_modules/axios to 1.16.0 before remediation", + ); + }); +}); diff --git a/test/openclaw-optional-plugin-build.test.ts b/test/openclaw-optional-plugin-build.test.ts index 35315e6e087..b044ced1f5f 100644 --- a/test/openclaw-optional-plugin-build.test.ts +++ b/test/openclaw-optional-plugin-build.test.ts @@ -10,9 +10,9 @@ import { writeReviewedNpmFixture } from "./helpers/reviewed-npm-fixture"; const ROOT = path.resolve(import.meta.dirname, ".."); const BRAVE_INTEGRITY = - "sha512-DDRnb4reL99O8kbISNbRFyk/xoUPYHsXG3UGikKAsVs+zIldYYA0hY0d3Z2aWoE+0vfda27mJUByCo7Xr15qdw=="; + "sha512-7Z+GZ/6K6a8LlkTsWVnAZ1hv8EarORzHQvFHD7ekcg033FGJOXYPEZSbvvE3qR9vM+vnoZplNjMZ7vFMRcvQgw=="; const BRAVE_TARBALL = - "https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.10.tgz"; + "https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.7.1.tgz"; it("pins Brave web-search and preserves its placeholder during build-time doctor", () => { const dockerfile = fs.readFileSync(path.join(ROOT, "Dockerfile"), "utf-8"); @@ -32,7 +32,7 @@ it("pins Brave web-search and preserves its placeholder during build-time doctor writeReviewedNpmFixture(npmFixture, log, [ { integrity: BRAVE_INTEGRITY, - packageSpec: "@openclaw/brave-plugin@2026.6.10", + packageSpec: "@openclaw/brave-plugin@2026.7.1", tarballUrl: BRAVE_TARBALL, }, ]); @@ -57,13 +57,13 @@ it("pins Brave web-search and preserves its placeholder during build-time doctor NEMOCLAW_WEB_SEARCH_ENABLED: "1", NEMOCLAW_WEB_SEARCH_PROVIDER: "brave", NODE_OPTIONS: "", - OPENCLAW_BRAVE_PLUGIN_2026_6_10_INTEGRITY: BRAVE_INTEGRITY, - OPENCLAW_VERSION: "2026.6.10", + OPENCLAW_BRAVE_PLUGIN_2026_7_1_INTEGRITY: BRAVE_INTEGRITY, + OPENCLAW_VERSION: "2026.7.1", }, }); const calls = fs.readFileSync(log, "utf-8"); expect(result.status, result.stderr).toBe(0); - expect(calls).toContain("npm view @openclaw/brave-plugin@2026.6.10 dist.integrity"); + expect(calls).toContain("npm view @openclaw/brave-plugin@2026.7.1 dist.integrity"); expect(calls).toContain(`npm pack ${BRAVE_TARBALL} --pack-destination`); expect(calls).toContain("plugins install npm-pack:"); expect(calls).toContain( diff --git a/test/openclaw-real-patched-dist-harness.test.ts b/test/openclaw-real-patched-dist-harness.test.ts index c3b12bd5eb4..86b6801b605 100644 --- a/test/openclaw-real-patched-dist-harness.test.ts +++ b/test/openclaw-real-patched-dist-harness.test.ts @@ -24,7 +24,7 @@ const PATCH_OPENCLAW_ISSUE_4434_DIAGNOSTICS = path.join( // timings as the real-artifact limit. const PATCH_COMMAND_TIMEOUT_MS = 120_000; // The compiled-dist classifier performs several full-tree grep/sed passes. -// A cold 2026.6.10 materialization can exceed three minutes on macOS while the +// A cold 2026.7.1 materialization can exceed three minutes on macOS while the // same patch completes normally; keep this bounded below the 12-minute CI job. const DOCKERFILE_PATCH_TIMEOUT_MS = 300_000; @@ -292,8 +292,8 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( () => { it("materializes the reviewed tarball and applies NemoClaw's Dockerfile OpenClaw patches", async () => { const version = readRequiredDockerArg("OPENCLAW_VERSION"); - const integrity = readRequiredDockerArg("OPENCLAW_2026_6_10_INTEGRITY"); - const tarballUrl = readRequiredDockerArg("OPENCLAW_2026_6_10_TARBALL"); + const integrity = readRequiredDockerArg("OPENCLAW_2026_7_1_INTEGRITY"); + const tarballUrl = readRequiredDockerArg("OPENCLAW_2026_7_1_TARBALL"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-real-dist-")); try { const tarballPath = materializeReviewedTarball(tarballUrl, tmp, integrity); diff --git a/test/openclaw-tool-search-runtime-validator.test.ts b/test/openclaw-tool-search-runtime-validator.test.ts index 96cf663df6e..a8108852284 100644 --- a/test/openclaw-tool-search-runtime-validator.test.ts +++ b/test/openclaw-tool-search-runtime-validator.test.ts @@ -225,12 +225,12 @@ describe("OpenClaw Tool Search pinned-runtime validator", () => { expect(result.visibleToolNames).toEqual(["nemoclaw_runtime_validator_probe"]); }); - it("selects the exact 2026.6.10 agent-tools runtime layout", async () => { + it("selects the exact 2026.7.1 agent-tools runtime layout", async () => { const fixture = writeFixture({ runtimeFileName: "agent-tools-fixture.js", - version: "2026.6.10", + version: "2026.7.1", }); - const result = await validateFixture(fixture, "progressive", "2026.6.10"); + const result = await validateFixture(fixture, "progressive", "2026.7.1"); expect(result.runtimeModulePath).toMatch(/agent-tools-fixture\.js$/); }); diff --git a/test/package-contract/msteams-message-hints-preload.test.ts b/test/package-contract/msteams-message-hints-preload.test.ts index 140628a9bb6..f35620889e7 100644 --- a/test/package-contract/msteams-message-hints-preload.test.ts +++ b/test/package-contract/msteams-message-hints-preload.test.ts @@ -22,17 +22,17 @@ const compiledPreload = path.join( // Reviewed from the published @openclaw/msteams artifact, not inferred from // NemoClaw source. The integrity is npm's dist.integrity; the SHA-256 values -// identify the exact runtime entry and plugin entry reviewed for 2026.6.10. +// identify the exact runtime entry and plugin entry reviewed for 2026.7.1. // This fixture intentionally models only that package/load boundary. It does // not vendor or claim to test the upstream Bot Framework send/parser code. const REVIEWED_MSTEAMS_CONTRACT = { - version: "2026.6.10", + version: "2026.7.1", npmIntegrity: - "sha512-GjHnCPvjbnI0C7mEFcdT2uKDH4/WwOe2dZBfQiWxBtkE76m6TNG0J9dJjD4mc8/pk8rXSO0cWw+KV9jzWtF9VA==", + "sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg==", runtimeExtension: "./dist/index.js", pluginSpecifier: "./channel-plugin-api.js", indexSha256: "2a83ee979d5ee9f12c7ac507ebd87024be3315de3f2cc87c81effc9ca85246d1", - pluginEntrySha256: "2d451b31ba4fbcc0e22ea4654fdc55dc05ae680765b7d636bfbf89177eb1be4b", + pluginEntrySha256: "3f155003264d64d92f780eae17eab48ebe18d56e67dacdc8f0587a1f09266165", } as const; function readPinnedOpenClawVersion(): string { @@ -58,7 +58,7 @@ function writeReviewedPackageShape(root: string, version: string): string { fs.writeFileSync( path.join(distDir, "reviewed-channel-entry-contract.js"), // The published package's runtime extension delegates to - // defineBundledChannelEntry. OpenClaw 2026.6.10 then uses createRequire for + // defineBundledChannelEntry. OpenClaw 2026.7.1 then uses createRequire for // built dist/*.js plugin entries. Preserve that reviewed loader seam here // without copying the upstream Teams sender or parser implementation. [ diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 0914808b3e4..6b11c5e1363 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -114,6 +114,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "patch-openclaw-device-self-approval.mts")); writeFixture(path.join("scripts", "verify-wechat-runtime-lock.mts")); writeFixture(path.join("scripts", "lib", "reviewed-npm-archive.mts"), "fixture\n", 0o700); + writeFixture(path.join("scripts", "lib", "openclaw-npm-remediation.mts"), "fixture\n", 0o700); fs.chmodSync(path.join(sourceRoot, "scripts"), 0o700); fs.chmodSync(path.join(sourceRoot, "scripts", "lib"), 0o700); } diff --git a/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts b/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts index 676b8f6bbce..c07e220625c 100644 --- a/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts +++ b/tools/e2e/openclaw-plugin-runtime-exdev-workflow-boundary.mts @@ -14,7 +14,7 @@ const DEFAULT_WORKFLOW_PATH = join(REPO_ROOT, ".github", "workflows", "e2e.yaml" const JOB_NAME = "openclaw-plugin-runtime-exdev"; const FULL_SHA_ACTION = /^[^\s@]+@[0-9a-f]{40}$/u; const RELEASE_BUILDER_IMAGE = - "node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d"; + "node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba"; type WorkflowStep = { env?: Record; diff --git a/tools/e2e/openshell-gateway-auth-contract-workflow-boundary.mts b/tools/e2e/openshell-gateway-auth-contract-workflow-boundary.mts index 72c5359573a..74cf283c2fc 100644 --- a/tools/e2e/openshell-gateway-auth-contract-workflow-boundary.mts +++ b/tools/e2e/openshell-gateway-auth-contract-workflow-boundary.mts @@ -16,7 +16,7 @@ const FULL_SHA_ACTION = /^[^\s@]+@[0-9a-f]{40}$/u; const EXPLICIT_ONLY_CONDITION = "${{ contains(format(',{0},', inputs.jobs), ',openshell-gateway-auth-contract,') || contains(format(',{0},', inputs.targets), ',openshell-gateway-auth-contract,') }}"; const GATEWAY_PROBE_IMAGE = - "node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d"; + "node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba"; type WorkflowStep = { env?: Record; From a3ca9a0355e17c563e7e07b42e2b48397f4be034 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 20 Jul 2026 22:27:19 -0400 Subject: [PATCH 02/24] fix(security): normalize remediated package integrity Signed-off-by: Julie Yaunches --- .../openclaw-2026.7.1-dependency-review.md | 10 ++-- scripts/lib/openclaw-npm-remediation.mts | 53 +++++++++++++++---- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 7e1134769a9..87f45281154 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -36,12 +36,12 @@ whose amd64 config reports Node `22.23.1`. - `sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg==` - `@openclaw/slack@2026.7.1` - `sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA==` - - remediated archive: `sha512-ctU4iNWpx3IDPDXqjRdU4TvzhM/dXUvuDXJHcl82/gUTMOFHO8bW+2UTTTKTNAmZbPz/YBeztJb6oaJfxxusvw==` + - remediated package tree: `sha512-4ThnsNS+yBlFSkTaQn2xosxrDu1s0vrxcqka5QqFj+8dCEaTa9JVLRgNniYV/QNhO53wc7a2R5oQFElzYspT2w==` - `@openclaw/whatsapp@2026.7.1` - `sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ==` - `@openclaw/msteams@2026.7.1` - `sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg==` - - remediated archive: `sha512-qtdnGvSnxaOJPG5nY/qEhXQzZoJIqnzp+3jaq2DWVB74T+zBdb9i/KVsiGFloMSjXx/pg8+i+nkhKFTaEOYHZg==` + - remediated package tree: `sha512-FL4l65gEbbwtDd9Ogr69+xBNzIfE4YS8Hib36G+kcmX+T0oB1zL+/qs6b4bJc+ygTsh60H3yqpFbXoQeN05JYQ==` - `@zed-industries/codex-acp@0.11.1` - `sha512-My2VSlBtvJipJhImHjFDej2ut/p00QqOISRnZgLgLrSIzjgvdcQvAhaZviWj7XPhk4UIdIb0OoA+Lrls824uiQ==` - `https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.11.1.tgz` @@ -70,8 +70,10 @@ plugin archives with this exact replacement graph: `scripts/lib/openclaw-npm-remediation.mts` verifies the original plugin and replacement package identities before it writes the archive. It rejects an upstream graph that no longer resolves Axios `1.16.0`. It then verifies the -deterministic remediated archive integrity before installation. The production -plugin installer and `reviewed-npm-audit` use this same function. +deterministic remediated package-tree integrity before installation. This +canonical tree digest is independent of npm-generated tar metadata, which can +vary between npm patch releases without changing package contents. The +production plugin installer and `reviewed-npm-audit` use this same function. This remediation is limited to `@openclaw/slack@2026.7.1` and `@openclaw/msteams@2026.7.1`. Remove it when a reviewed stable OpenClaw plugin diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index b4faccfa324..5b4e3add2a9 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -7,8 +7,10 @@ import { createHash } from "node:crypto"; import { cpSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, writeFileSync, @@ -20,7 +22,7 @@ import { packReviewedNpmArchive } from "./reviewed-npm-archive.mts"; type JsonObject = Record; type Remediation = Readonly<{ - expectedPatchedIntegrity: string; + expectedPatchedTreeIntegrity: string; }>; type RemediationRequest = Readonly<{ @@ -32,7 +34,7 @@ type RemediationRequest = Readonly<{ type BuildRequest = RemediationRequest & Readonly<{ - expectedPatchedIntegrity?: string; + expectedPatchedTreeIntegrity?: string; }>; export type RemediatedArchive = Readonly<{ @@ -57,12 +59,12 @@ const AGENT_BASE_TARBALL = "https://registry.npmjs.org/agent-base/-/agent-base-6 const REMEDIATIONS: Readonly> = Object.freeze({ "@openclaw/msteams@2026.7.1": { - expectedPatchedIntegrity: - "sha512-qtdnGvSnxaOJPG5nY/qEhXQzZoJIqnzp+3jaq2DWVB74T+zBdb9i/KVsiGFloMSjXx/pg8+i+nkhKFTaEOYHZg==", + expectedPatchedTreeIntegrity: + "sha512-FL4l65gEbbwtDd9Ogr69+xBNzIfE4YS8Hib36G+kcmX+T0oB1zL+/qs6b4bJc+ygTsh60H3yqpFbXoQeN05JYQ==", }, "@openclaw/slack@2026.7.1": { - expectedPatchedIntegrity: - "sha512-ctU4iNWpx3IDPDXqjRdU4TvzhM/dXUvuDXJHcl82/gUTMOFHO8bW+2UTTTKTNAmZbPz/YBeztJb6oaJfxxusvw==", + expectedPatchedTreeIntegrity: + "sha512-4ThnsNS+yBlFSkTaQn2xosxrDu1s0vrxcqka5QqFj+8dCEaTa9JVLRgNniYV/QNhO53wc7a2R5oQFElzYspT2w==", }, }); @@ -140,6 +142,29 @@ function writeJson(path: string, value: JsonObject): void { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); } +function hashPackageTree(packageDirectory: string): string { + const hash = createHash("sha512"); + const visit = (directory: string, relativeDirectory: string): void => { + for (const name of readdirSync(directory).sort()) { + const absolutePath = join(directory, name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name; + const stats = lstatSync(absolutePath); + if (stats.isDirectory()) { + hash.update(`directory\0${relativePath}\0`); + visit(absolutePath, relativePath); + } else if (stats.isFile()) { + hash.update(`file\0${relativePath}\0${stats.size}\0`); + hash.update(readFileSync(absolutePath)); + hash.update("\0"); + } else { + throw new Error(`Remediated package tree has unsupported entry ${relativePath}`); + } + } + }; + visit(packageDirectory, ""); + return `sha512-${hash.digest("base64")}`; +} + function sortedObject(value: JsonObject): JsonObject { return Object.fromEntries( Object.entries(value).sort(([left], [right]) => left.localeCompare(right)), @@ -394,10 +419,20 @@ export function buildRemediatedOpenClawPluginArchive(request: BuildRequest): Rem } const archivePath = resolve(outputDirectory, basename(packed[0].filename)); validateArchiveMembers(archivePath, remediationRoot, env); + const packedPackage = extractArchive( + archivePath, + join(remediationRoot, "packed-output"), + remediationRoot, + env, + ); + const treeIntegrity = hashPackageTree(packedPackage); const integrity = `sha512-${createHash("sha512").update(readFileSync(archivePath)).digest("base64")}`; - if (request.expectedPatchedIntegrity && integrity !== request.expectedPatchedIntegrity) { + if ( + request.expectedPatchedTreeIntegrity && + treeIntegrity !== request.expectedPatchedTreeIntegrity + ) { throw new Error( - `Remediated ${request.packageSpec} integrity mismatch: expected ${request.expectedPatchedIntegrity}, got ${integrity}`, + `Remediated ${request.packageSpec} tree integrity mismatch: expected ${request.expectedPatchedTreeIntegrity}, got ${treeIntegrity}`, ); } return { archivePath, integrity, remediated: true }; @@ -418,7 +453,7 @@ export function remediateReviewedOpenClawPluginArchive( } return buildRemediatedOpenClawPluginArchive({ ...request, - expectedPatchedIntegrity: remediation.expectedPatchedIntegrity, + expectedPatchedTreeIntegrity: remediation.expectedPatchedTreeIntegrity, }); } From 9317a2bcd9899035f58bd5629e95d9858971356f Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Mon, 20 Jul 2026 22:43:10 -0400 Subject: [PATCH 03/24] fix(security): complete OpenClaw 2026.7.1 validation Signed-off-by: Julie Yaunches --- .../openclaw-2026.7.1-dependency-review.md | 9 +- scripts/validate-openclaw-tool-search.mts | 171 ++++++++++-------- .../applier/build/messaging-build-applier.mts | 2 +- .../messaging-build-applier-integrity.test.ts | 19 +- test/messaging-build-applier.test.ts | 29 ++- test/openclaw-integrity-pin-suite.ts | 19 +- test/openclaw-npm-remediation.test.ts | 14 +- ...claw-tool-search-runtime-validator.test.ts | 5 +- 8 files changed, 176 insertions(+), 92 deletions(-) diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 87f45281154..8ad429cbc94 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -80,10 +80,11 @@ This remediation is limited to `@openclaw/slack@2026.7.1` and release bundles Axios `>=1.18.0` and passes the repository audit. The reviewed installer verifies each registry identity and downloaded tarball -integrity. `scripts/lib/reviewed-npm-archive.mts` uses `npm pack --json`, rejects -reported archive filenames containing unsafe archive paths, binds reviewed npm -installs to verified local archives, checks each reviewed npm plugin registry -integrity, and returns only the verified local `.tgz` path. +integrity. `scripts/lib/reviewed-npm-archive.mts` uses `npm pack --json` and +rejects reported archive filenames containing unsafe archive paths. Its checks +bind reviewed npm installs to verified local archives and check each reviewed +npm plugin registry integrity. The helper returns only the verified local `.tgz` +path. ## OpenClaw Compiled-Dist Patch Runtime Boundary diff --git a/scripts/validate-openclaw-tool-search.mts b/scripts/validate-openclaw-tool-search.mts index f886f8cf525..116d68a3461 100755 --- a/scripts/validate-openclaw-tool-search.mts +++ b/scripts/validate-openclaw-tool-search.mts @@ -12,12 +12,35 @@ const RUNTIME_FUNCTION_NAMES = [ "createOpenClawCodingTools", "applyToolSearchCatalog", ] as const; -const RUNTIME_MODULE_FILE_PATTERNS = new Map([ - ["2026.5.27", /^pi-tools-.*\.js$/], - ["2026.6.10", /^agent-tools-.*\.js$/], - ["2026.7.1", /^agent-tools-.*\.js$/], -]); type RuntimeFunctionName = (typeof RUNTIME_FUNCTION_NAMES)[number]; +const RUNTIME_MODULE_FILE_PATTERNS = new Map>>( + [ + [ + "2026.5.27", + { + resolveToolSearchConfig: /^pi-tools-.*\.js$/, + createOpenClawCodingTools: /^pi-tools-.*\.js$/, + applyToolSearchCatalog: /^pi-tools-.*\.js$/, + }, + ], + [ + "2026.6.10", + { + resolveToolSearchConfig: /^agent-tools-.*\.js$/, + createOpenClawCodingTools: /^agent-tools-.*\.js$/, + applyToolSearchCatalog: /^agent-tools-.*\.js$/, + }, + ], + [ + "2026.7.1", + { + resolveToolSearchConfig: /^tool-search-.*\.js$/, + createOpenClawCodingTools: /^agent-tools-.*\.js$/, + applyToolSearchCatalog: /^tool-search-.*\.js$/, + }, + ], + ], +); type ExpectedMode = "progressive" | "direct"; interface JsonRecord { [key: string]: unknown; @@ -125,10 +148,6 @@ function isObjectRecord(value: unknown): value is JsonRecord { return value !== null && typeof value === "object" && !Array.isArray(value); } -function isRuntimeFunctionName(value: string): value is RuntimeFunctionName { - return (RUNTIME_FUNCTION_NAMES as readonly string[]).includes(value); -} - function readJson(filePath: string, label: string): JsonRecord { let text: string; try { @@ -152,15 +171,22 @@ function countFunctionDeclarations(source: string, functionName: RuntimeFunction return [...source.matchAll(new RegExp(`\\bfunction\\s+${escapedName}\\s*\\(`, "g"))].length; } -function runtimeModuleFilePattern(expectedVersion: string): RegExp { - const pattern = RUNTIME_MODULE_FILE_PATTERNS.get(expectedVersion); - if (pattern === undefined) { +function runtimeModuleFilePattern( + expectedVersion: string, + functionName: RuntimeFunctionName, +): RegExp { + const layout = RUNTIME_MODULE_FILE_PATTERNS.get(expectedVersion); + if (layout === undefined) { fail(`no compiled runtime module layout is registered for OpenClaw ${expectedVersion}`); } - return pattern; + return layout[functionName]; } -function readRuntimeCandidates(distDir: string, expectedVersion: string): RuntimeCandidate[] { +function readRuntimeCandidates( + distDir: string, + expectedVersion: string, + functionName: RuntimeFunctionName, +): RuntimeCandidate[] { let entries: fs.Dirent[]; try { entries = fs.readdirSync(distDir, { withFileTypes: true }); @@ -168,7 +194,7 @@ function readRuntimeCandidates(distDir: string, expectedVersion: string): Runtim fail(`could not read OpenClaw dist directory ${distDir}: ${errorMessage(error)}`); } - const filePattern = runtimeModuleFilePattern(expectedVersion); + const filePattern = runtimeModuleFilePattern(expectedVersion, functionName); const candidates: RuntimeCandidate[] = []; for (const entry of entries) { if (!entry.isFile() || !filePattern.test(entry.name)) continue; @@ -179,40 +205,44 @@ function readRuntimeCandidates(distDir: string, expectedVersion: string): Runtim } catch (error) { fail(`could not read compiled runtime candidate ${filePath}: ${errorMessage(error)}`); } - if (RUNTIME_FUNCTION_NAMES.every((name) => source.includes(`function ${name}`))) { + if (source.includes(`function ${functionName}`)) { candidates.push({ filePath, source }); } } return candidates; } -function locateRuntimeModule(distDir: string, expectedVersion: string): RuntimeCandidate { - const candidates = readRuntimeCandidates(distDir, expectedVersion); - if (candidates.length !== 1) { - fail( - `expected exactly one registered OpenClaw ${expectedVersion} runtime module containing ${RUNTIME_FUNCTION_NAMES.join( - ", ", - )}; found ${candidates.length}`, - ); - } - const candidate = candidates[0]; - if (!candidate) fail("compiled runtime candidate disappeared after cardinality check"); +function locateRuntimeModules( + distDir: string, + expectedVersion: string, +): Map { + const modules = new Map(); for (const functionName of RUNTIME_FUNCTION_NAMES) { + const candidates = readRuntimeCandidates(distDir, expectedVersion, functionName); + if (candidates.length !== 1) { + fail( + `expected exactly one registered OpenClaw ${expectedVersion} runtime module containing ${functionName}; found ${candidates.length}`, + ); + } + const candidate = candidates[0]; + if (!candidate) fail("compiled runtime candidate disappeared after cardinality check"); const count = countFunctionDeclarations(candidate.source, functionName); if (count !== 1) { fail( `${candidate.filePath} must declare compiled function ${functionName} exactly once; found ${count}`, ); } + modules.set(functionName, candidate); } - return candidate; + return modules; } -function parseRuntimeExportAliases( +function parseRuntimeExportAlias( source: string, filePath: string, -): Map { - const aliases = new Map(); + functionName: RuntimeFunctionName, +): string { + let alias: string | undefined; const exportBlocks = [...source.matchAll(/\bexport\s*\{([\s\S]*?)\}\s*;?/g)]; for (const block of exportBlocks) { const blockBody = block[1]; @@ -225,58 +255,45 @@ function parseRuntimeExportAliases( ); if (!match) continue; const localName = match[1]; - if (localName === undefined || !isRuntimeFunctionName(localName)) continue; - if (aliases.has(localName)) { - fail(`${filePath} exports compiled function ${localName} more than once`); + if (localName !== functionName) continue; + if (alias !== undefined) { + fail(`${filePath} exports compiled function ${functionName} more than once`); } - aliases.set(localName, match[2] ?? localName); - } - } - - for (const functionName of RUNTIME_FUNCTION_NAMES) { - if (!aliases.has(functionName)) { - fail(`${filePath} does not export compiled function ${functionName}`); + alias = match[2] ?? localName; } } - if (new Set(aliases.values()).size !== RUNTIME_FUNCTION_NAMES.length) { - fail(`${filePath} reuses an export alias across required compiled functions`); - } - return aliases; -} - -function requiredAlias( - aliases: ReadonlyMap, - functionName: RuntimeFunctionName, - filePath: string, -): string { - const alias = aliases.get(functionName); if (alias === undefined) fail(`${filePath} does not export compiled function ${functionName}`); return alias; } async function importRuntimeFunctions( - filePath: string, - aliases: ReadonlyMap, + modules: ReadonlyMap, ): Promise { - const moduleUrl = pathToFileURL(filePath); - moduleUrl.searchParams.set( - "nemoclaw_tool_search_validator", - `${process.pid}-${Date.now()}-${importSequence++}`, - ); - - let runtimeModule: JsonRecord; - try { - runtimeModule = await import(moduleUrl.href); - } catch (error) { - fail(`could not import compiled runtime ${filePath}: ${errorMessage(error)}`); - } - + const importedModules = new Map(); const runtimeExports = new Map unknown>(); for (const functionName of RUNTIME_FUNCTION_NAMES) { - const exportName = requiredAlias(aliases, functionName, filePath); + const candidate = modules.get(functionName); + if (!candidate) fail(`compiled runtime module for ${functionName} disappeared`); + let runtimeModule = importedModules.get(candidate.filePath); + if (!runtimeModule) { + const moduleUrl = pathToFileURL(candidate.filePath); + moduleUrl.searchParams.set( + "nemoclaw_tool_search_validator", + `${process.pid}-${Date.now()}-${importSequence++}`, + ); + try { + runtimeModule = await import(moduleUrl.href); + } catch (error) { + fail(`could not import compiled runtime ${candidate.filePath}: ${errorMessage(error)}`); + } + if (!runtimeModule) fail(`compiled runtime import for ${functionName} disappeared`); + importedModules.set(candidate.filePath, runtimeModule); + } + if (!runtimeModule) fail(`compiled runtime import for ${functionName} disappeared`); + const exportName = parseRuntimeExportAlias(candidate.source, candidate.filePath, functionName); const value = runtimeModule[exportName]; if (typeof value !== "function") { - fail(`${filePath} export ${exportName} for ${functionName} is not a function`); + fail(`${candidate.filePath} export ${exportName} for ${functionName} is not a function`); } runtimeExports.set(functionName, value as (...args: never[]) => unknown); } @@ -579,15 +596,21 @@ export async function validateOpenClawToolSearchRuntime({ const version = assertExpectedVersion(resolvedDist, expectedVersion); const config = readJson(resolvedConfigPath, "generated OpenClaw config"); readToolSearchConfig(config, validatedMode, resolvedConfigPath); - const { filePath, source } = locateRuntimeModule(resolvedDist, version); - const aliases = parseRuntimeExportAliases(source, filePath); - const runtime = await importRuntimeFunctions(filePath, aliases); + const runtimeModules = locateRuntimeModules(resolvedDist, version); + const runtime = await importRuntimeFunctions(runtimeModules); assertResolvedConfig(runtime.resolveToolSearchConfig, config, validatedMode); const visibleToolNames = validatedMode === "progressive" ? await validateProgressiveRuntime(runtime, config) : await validateDirectRuntime(runtime, config); - return { version, expectedMode: validatedMode, runtimeModulePath: filePath, visibleToolNames }; + const toolModule = runtimeModules.get("createOpenClawCodingTools"); + if (!toolModule) fail("compiled createOpenClawCodingTools module disappeared"); + return { + version, + expectedMode: validatedMode, + runtimeModulePath: toolModule.filePath, + visibleToolNames, + }; } function usage(): string { diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 461a6befac5..e6cfdab7835 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -1301,7 +1301,7 @@ function packVerifiedOpenClawPluginArchive( const remediated = remediateReviewedOpenClawPluginArchive({ archivePath: archive.archivePath, env: env as NodeJS.ProcessEnv, - packageSpec: `${exactPackage.packageSpec}@${exactPackage.version}`, + packageSpec: exactPackage.packageSpec, workingDirectory: archive.rootDirectory, }); return { archivePath: remediated.archivePath, rootDir: archive.rootDirectory }; diff --git a/test/messaging-build-applier-integrity.test.ts b/test/messaging-build-applier-integrity.test.ts index b4b0e3467d7..f60085e5a36 100644 --- a/test/messaging-build-applier-integrity.test.ts +++ b/test/messaging-build-applier-integrity.test.ts @@ -6,7 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { applyMessagingBuildPhase, OPENCLAW_MESSAGING_PLUGIN_ARCHIVE_PROVENANCE_POLICY, @@ -16,6 +16,23 @@ import { import { testTimeout } from "./helpers/timeouts"; import { withLegacyMessagingPlanEnvDirect } from "./messaging-plan-test-helper"; +vi.mock("../scripts/lib/openclaw-npm-remediation.mts", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + remediateReviewedOpenClawPluginArchive: ({ archivePath }: { archivePath: string }) => ({ + archivePath, + integrity: "sha512-messaging-integrity-test-remediation", + remediated: false, + }), + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + const SCRIPT_PATH = path.join( import.meta.dirname, "..", diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 1594c868973..52e33cfec35 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -7,7 +7,7 @@ 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 { beforeEach, describe, expect, it, vi } from "vitest"; import { applyMessagingBuildPhase, describeMessagingBuildPhase, @@ -17,6 +17,27 @@ import { import { execTimeout, testTimeout } from "./helpers/timeouts"; import { withLegacyMessagingPlanEnvDirect } from "./messaging-plan-test-helper"; +const { remediateReviewedArchive } = vi.hoisted(() => ({ + remediateReviewedArchive: vi.fn(({ archivePath }: { archivePath: string }) => ({ + archivePath, + integrity: "sha512-messaging-test-remediation", + remediated: false, + })), +})); + +vi.mock("../scripts/lib/openclaw-npm-remediation.mts", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + remediateReviewedOpenClawPluginArchive: remediateReviewedArchive, + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + const SCRIPT_PATH = path.join( import.meta.dirname, "..", @@ -676,6 +697,9 @@ describe("messaging-build-applier.mts: agent-install", () => { ); expect(trace).toContain("openclaw|plugins|install|npm-pack:"); expect(trace).toContain("msteams-2026.7.1.tgz|"); + expect(remediateReviewedArchive).toHaveBeenCalledWith( + expect.objectContaining({ packageSpec: "@openclaw/msteams@2026.7.1" }), + ); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -958,6 +982,9 @@ describe("messaging-build-applier.mts: agent-install", () => { ); expect(trace).toContain("openclaw|plugins|install|npm-pack:"); expect(trace).toContain("slack-2026.7.1.tgz|"); + expect(remediateReviewedArchive).toHaveBeenCalledWith( + expect.objectContaining({ packageSpec: "@openclaw/slack@2026.7.1" }), + ); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/openclaw-integrity-pin-suite.ts b/test/openclaw-integrity-pin-suite.ts index 8e387739e6f..69982a50b65 100644 --- a/test/openclaw-integrity-pin-suite.ts +++ b/test/openclaw-integrity-pin-suite.ts @@ -39,7 +39,7 @@ const REVIEWED_NPM_ARCHIVE_HELPER = path.join( "lib", "reviewed-npm-archive.mts", ); -const UNPINNED_OPENCLAW_VERSION = "2026.6.11"; +const UNPINNED_OPENCLAW_VERSION = "2026.7.2"; const PINNED_OPENCLAW_VERSION = "2026.7.1"; const PINNED_OPENCLAW_INTEGRITY = "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g=="; @@ -454,7 +454,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes describe("OpenClaw npm integrity pins", () => { if (group === "contract") { it("keeps the advisory review note aligned with the committed OpenClaw pin", () => { - const reviewNote = fs.readFileSync(DEPENDENCY_REVIEW_NOTE, "utf-8"); + const reviewNote = fs.readFileSync(DEPENDENCY_REVIEW_NOTE, "utf-8").replace(/\s+/g, " "); expect(reviewNote).toContain(`openclaw@${PINNED_OPENCLAW_VERSION}`); expect(reviewNote).toContain(PINNED_OPENCLAW_INTEGRITY); @@ -497,9 +497,10 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(reviewNote).toContain("@openclaw/diagnostics-otel@2026.7.1"); expect(reviewNote).toContain("@openclaw/brave-plugin@2026.7.1"); expect(reviewNote).toContain("@tencent-weixin/openclaw-weixin@2.4.3"); - expect(reviewNote).toContain("`1` high"); + expect(reviewNote).toContain("`1` moderate"); + expect(reviewNote).toContain("`0` high"); expect(reviewNote).toContain("`0` critical"); - expect(reviewNote).toContain("`818` total dependencies"); + expect(reviewNote).toContain("`822` total dependencies"); expect(reviewNote).toContain( "`dist/pipeline.runtime-*.js`, which exports `prepareSlackMessage`", ); @@ -627,7 +628,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes "npm pack https://registry.npmjs.org/@openclaw/diagnostics-otel/-/diagnostics-otel-2026.7.1.tgz --pack-destination", ); expect(calls).toMatch( - /openclaw plugins install npm-pack:\S*\/diagnostics-otel-2026\.6\.10\.tgz\n/, + /openclaw plugins install npm-pack:\S*\/diagnostics-otel-2026\.7\.1\.tgz\n/, ); expect(calls).toContain( `npm view @openclaw/brave-plugin@${PINNED_OPENCLAW_VERSION} dist.integrity`, @@ -639,7 +640,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes "npm pack https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.7.1.tgz --pack-destination", ); expect(calls).toMatch( - /openclaw plugins install npm-pack:\S*\/brave-plugin-2026\.6\.10\.tgz\n/, + /openclaw plugins install npm-pack:\S*\/brave-plugin-2026\.7\.1\.tgz\n/, ); expect(calls).toContain("openclaw-env true true"); }); @@ -666,7 +667,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes it("fails closed before optional OpenClaw plugin install when the registry tarball URL drifts", () => { const driftedTarball = - "https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.6.11.tgz"; + "https://registry.npmjs.org/@openclaw/brave-plugin/-/brave-plugin-2026.7.2.tgz"; const { result, calls } = runOptionalOpenClawPluginBlock({ otel: false, braveRegistryTarball: driftedTarball, @@ -973,7 +974,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes ), { openclawVersion: PINNED_OPENCLAW_VERSION, - installedOpenClawVersion: "2026.6.11", + installedOpenClawVersion: UNPINNED_OPENCLAW_VERSION, committedIntegrity: PINNED_OPENCLAW_INTEGRITY, registryIntegrity: PINNED_OPENCLAW_INTEGRITY, baseProvenance: openClawBaseProvenance(), @@ -982,7 +983,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(result.status).not.toBe(0); expect(`${result.stdout}${result.stderr}`).toContain( - `Base image has OpenClaw 2026.6.11, which is newer than reviewed target ${PINNED_OPENCLAW_VERSION}`, + `Base image has OpenClaw ${UNPINNED_OPENCLAW_VERSION}, which is newer than reviewed target ${PINNED_OPENCLAW_VERSION}`, ); expect(calls).not.toContain(`npm view openclaw@${PINNED_OPENCLAW_VERSION} dist.integrity`); expect(calls).not.toContain(`npm pack ${PINNED_OPENCLAW_TARBALL} --pack-destination`); diff --git a/test/openclaw-npm-remediation.test.ts b/test/openclaw-npm-remediation.test.ts index ff30ff04fca..d0f74894ccf 100644 --- a/test/openclaw-npm-remediation.test.ts +++ b/test/openclaw-npm-remediation.test.ts @@ -79,17 +79,29 @@ describe("OpenClaw npm remediation", () => { }>(path.join(directory, "npm-shrinkwrap.json")); expect(shrinkwrap.packages["node_modules/axios"]).toMatchObject({ version: "1.18.0", + resolved: "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + integrity: + "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", dependencies: { "https-proxy-agent": "^5.0.1" }, }); expect(shrinkwrap.packages["node_modules/axios/node_modules/https-proxy-agent"]).toMatchObject({ version: "5.0.1", + resolved: "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + integrity: + "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", dependencies: { "agent-base": "6" }, }); expect( shrinkwrap.packages[ "node_modules/axios/node_modules/https-proxy-agent/node_modules/agent-base" ], - ).toMatchObject({ version: "6.0.2", dependencies: { debug: "4" } }); + ).toMatchObject({ + version: "6.0.2", + resolved: "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + integrity: + "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + dependencies: { debug: "4" }, + }); }); it("rejects an upstream Axios graph that changed after review", () => { diff --git a/test/openclaw-tool-search-runtime-validator.test.ts b/test/openclaw-tool-search-runtime-validator.test.ts index a8108852284..2e24ef7d732 100644 --- a/test/openclaw-tool-search-runtime-validator.test.ts +++ b/test/openclaw-tool-search-runtime-validator.test.ts @@ -160,6 +160,7 @@ interface FixtureOptions { source?: string; version?: string; secondSource?: string; + secondRuntimeFileName?: string; } let tmpDir: string; @@ -187,7 +188,7 @@ function writeFixture(options: FixtureOptions = {}) { [options.runtimeFileName ?? "pi-tools-fixture.js", options.source ?? RUNTIME_FIXTURE_SOURCE], ...(options.secondSource === undefined ? [] - : [["pi-tools-second.js", options.secondSource] as const]), + : [[options.secondRuntimeFileName ?? "pi-tools-second.js", options.secondSource] as const]), ]; for (const [name, source] of runtimeSources) { fs.writeFileSync(path.join(distDir, name), source); @@ -228,6 +229,8 @@ describe("OpenClaw Tool Search pinned-runtime validator", () => { it("selects the exact 2026.7.1 agent-tools runtime layout", async () => { const fixture = writeFixture({ runtimeFileName: "agent-tools-fixture.js", + secondRuntimeFileName: "tool-search-fixture.js", + secondSource: RUNTIME_FIXTURE_SOURCE, version: "2026.7.1", }); const result = await validateFixture(fixture, "progressive", "2026.7.1"); From c308bcf291138d0c7a59af3d39cfad6da4f7bcd9 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 20 Jul 2026 22:14:16 -0700 Subject: [PATCH 04/24] fix(security): support OpenClaw 2026.7.1 startup Signed-off-by: Aaron Erickson --- Dockerfile | 47 +- Dockerfile.base | 5 +- .../openclaw-2026.7.1-dependency-review.md | 74 +- nemoclaw/package-lock.json | 8 +- nemoclaw/package.json | 2 +- scripts/lib/normalize_mutable_config_perms.py | 178 +++ scripts/nemoclaw-start.sh | 29 +- ...atch-openclaw-shared-state-permissions.mts | 718 ++++++++++++ ...ckerfile-remote-dashboard-bind-contract.ts | 4 +- src/lib/sandbox/build-context.ts | 4 + ...penclaw-real-device-self-approval-proof.ts | 19 +- test/openclaw-2026-7-startup-compat.test.ts | 187 +++ test/openclaw-dependency-review.test.ts | 14 + test/openclaw-integrity-pin-suite.ts | 31 + ...openclaw-real-patched-dist-harness.test.ts | 237 +++- ...law-shared-state-permissions-patch.test.ts | 1002 +++++++++++++++++ test/sandbox-build-context.test.ts | 6 + test/sandbox-provisioning.test.ts | 7 +- 18 files changed, 2535 insertions(+), 37 deletions(-) create mode 100644 scripts/patch-openclaw-shared-state-permissions.mts create mode 100644 test/openclaw-2026-7-startup-compat.test.ts create mode 100644 test/openclaw-shared-state-permissions-patch.test.ts diff --git a/Dockerfile b/Dockerfile index 76170d83379..0a953aab951 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,10 @@ RUN ln -s /opt/nemoclaw/node_modules /opt/nemoclaw-root/node_modules \ # hadolint ignore=DL3006 FROM ${BASE_IMAGE} ARG BASE_IMAGE +# Upgrade the final runtime even when an install or rebuild starts from a +# published sandbox base with Node 22.22.2. OpenClaw 2026.7.1 requires the +# SQLite WAL fix in Node 22.22.3 or newer. +COPY --from=builder /usr/local/bin/node /usr/local/bin/node # Dependency review evidence for this runtime pin lives in # docs/security/openclaw-2026.7.1-dependency-review.md. ARG OPENCLAW_VERSION=2026.7.1 @@ -81,6 +85,11 @@ ENV AWS_EC2_METADATA_DISABLED=true # names do not persist under /tmp/jiti inside the sandbox. ENV JITI_FS_CACHE=false +# NemoClaw always runs OpenClaw CLI work and the gateway as separate users in +# one shared group, including direct Docker entrypoint deployments that do not +# receive an OpenShell sandbox marker. +ENV NEMOCLAW_OPENCLAW_SHARED_STATE=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 # here; the RUN below decodes it to a root-owned file that the entrypoint @@ -216,12 +225,14 @@ COPY scripts/patch-openclaw-chat-send.mts /usr/local/lib/nemoclaw/patch-openclaw COPY scripts/patch-openclaw-mcp-npx.mts /usr/local/lib/nemoclaw/patch-openclaw-mcp-npx.mts COPY scripts/patch-openclaw-issue-4434-diagnostics.mts /usr/local/lib/nemoclaw/patch-openclaw-issue-4434-diagnostics.mts COPY scripts/patch-openclaw-device-self-approval.mts /usr/local/lib/nemoclaw/patch-openclaw-device-self-approval.mts +COPY scripts/patch-openclaw-shared-state-permissions.mts /usr/local/lib/nemoclaw/patch-openclaw-shared-state-permissions.mts COPY scripts/verify-wechat-runtime-lock.mts /usr/local/lib/nemoclaw/verify-wechat-runtime-lock.mts RUN chmod 755 /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts \ /usr/local/lib/nemoclaw/patch-openclaw-chat-send.mts \ /usr/local/lib/nemoclaw/patch-openclaw-mcp-npx.mts \ /usr/local/lib/nemoclaw/patch-openclaw-issue-4434-diagnostics.mts \ /usr/local/lib/nemoclaw/patch-openclaw-device-self-approval.mts \ + /usr/local/lib/nemoclaw/patch-openclaw-shared-state-permissions.mts \ /usr/local/lib/nemoclaw/verify-wechat-runtime-lock.mts # Pre-install the codex-acp package so the embedded ACPx runtime can @@ -780,6 +791,22 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-mcp-n RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.mts \ /usr/local/lib/node_modules/openclaw/dist +# OpenClaw 2026.7.1 moved gateway startup work into shared and per-agent SQLite +# databases, but hardens them to owner-only modes on every open. NemoClaw runs +# the CLI and gateway as separate users in the sandbox group, so use +# group-shared modes inside the NemoClaw image or an OpenShell sandbox. The +# patch keeps private-store directories setgid, avoids a non-owner chmod when +# the inherited mode is already safe, keeps generated models files readable by +# the shared group, and ignores the obsolete update-check cache migration that +# cannot archive across a shields-protected parent. +# +# Removal criteria: drop when upstream OpenClaw supports a split-user, +# group-shared state databases and split-user cache migrations without +# startup warnings. +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-shared-state-permissions.mts \ + /usr/local/lib/node_modules/openclaw/dist + # Set up blueprint for local resolution. # Blueprints are immutable at runtime; DAC protection (root ownership) is applied # later since /sandbox/.nemoclaw is Landlock read_write for plugin state (#804). @@ -1277,14 +1304,30 @@ RUN set -eu; \ "$config_dir/credentials" \ "$config_dir/flows" \ "$config_dir/sandbox" \ + "$config_dir/state" \ "$config_dir/telegram" \ "$config_dir/wechat" \ "$config_dir/media" \ "$config_dir/plugin-runtime-deps"; do \ install -d -o sandbox -g sandbox -m 2770 "$dir"; \ done; \ - for file in "$config_dir/update-check.json" "$config_dir/exec-approvals.json"; do \ - touch "$file"; \ + update_check="$config_dir/update-check.json"; \ + [ ! -L "$update_check" ] \ + || { echo "ERROR: refusing symlinked OpenClaw update-check state" >&2; exit 1; }; \ + [ ! -e "$update_check" ] || [ -f "$update_check" ] \ + || { echo "ERROR: refusing non-regular OpenClaw update-check state" >&2; exit 1; }; \ + rm -f "$update_check"; \ + touch "$config_dir/exec-approvals.json"; \ + chown sandbox:sandbox "$config_dir/exec-approvals.json"; \ + chmod 660 "$config_dir/exec-approvals.json"; \ + for file in \ + "$config_dir/state/openclaw.sqlite" \ + "$config_dir/state/openclaw.sqlite-wal" \ + "$config_dir/state/openclaw.sqlite-shm" \ + "$config_dir/state/openclaw.sqlite-journal"; do \ + [ -e "$file" ] || [ -L "$file" ] || continue; \ + [ -f "$file" ] && [ ! -L "$file" ] \ + || { echo "ERROR: refusing unsafe OpenClaw state file: $file" >&2; exit 1; }; \ chown sandbox:sandbox "$file"; \ chmod 660 "$file"; \ done; \ diff --git a/Dockerfile.base b/Dockerfile.base index 05d59607aca..8a3c8967305 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -121,6 +121,9 @@ RUN groupadd -r gateway && useradd -r -g gateway -d /sandbox -s /usr/sbin/nologi # Create .openclaw with all state subdirs directly (mutable by default). # No separate .openclaw-data or symlink bridge — the production Dockerfile # layers config on top and sets final permissions. +# Do not seed the legacy update-check.json file. OpenClaw 2026.7.1 treats an +# empty or populated file as a fatal migration warning when the separate +# gateway user cannot archive it through a shields-protected parent. # Ref: https://github.com/NVIDIA/NemoClaw/issues/514 RUN mkdir -p /sandbox/.openclaw/agents/main/agent \ /sandbox/.openclaw/extensions \ @@ -136,9 +139,9 @@ RUN mkdir -p /sandbox/.openclaw/agents/main/agent \ /sandbox/.openclaw/credentials \ /sandbox/.openclaw/flows \ /sandbox/.openclaw/sandbox \ + /sandbox/.openclaw/state \ /sandbox/.openclaw/telegram \ /sandbox/.openclaw/plugin-runtime-deps \ - && touch /sandbox/.openclaw/update-check.json \ && touch /sandbox/.openclaw/exec-approvals.json \ && chown -R sandbox:sandbox /sandbox/.openclaw \ && chmod -R g+w /sandbox/.openclaw \ diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 8ad429cbc94..2df297e1239 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -11,7 +11,9 @@ Pin the production OpenClaw runtime and matching official plugins to the non-prerelease `v2026.7.1` release. This replaces `2026.6.10`, whose bundled graph contains the newly disclosed critical `tar` advisory. The reviewed `openclaw@2026.7.1` graph contains `tar@7.5.19`; the audit report contains no -`tar` finding. +`tar` finding. NemoClaw's plugin also consumes `tar` directly for guarded +migration archives, so its manifest and lock move from `7.5.11` to `7.5.20`; +the exact plugin graph reports no vulnerabilities after that update. The release lineage is unusually wide and divergent: the direct upstream comparison reports 4,407 commits ahead and 34 behind. The maintainer requested @@ -47,6 +49,9 @@ whose amd64 config reports Node `22.23.1`. - `https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.11.1.tgz` - `@tencent-weixin/openclaw-weixin@2.4.3` - `sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==` +- `tar@7.5.20` (NemoClaw plugin direct dependency) + - `sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==` + - `https://registry.npmjs.org/tar/-/tar-7.5.20.tgz` ## Audit result and temporary Axios remediation @@ -55,6 +60,9 @@ The exact reviewed archive graph contains `822` total dependencies and reports finding that blocked the previous pin is gone. The remaining moderate `protobufjs` finding is below the configured `high` threshold. +The independently installed `nemoclaw/` plugin graph reports `0` +vulnerabilities after resolving its direct `tar` dependency to `7.5.20`. + The published Slack and Microsoft Teams plugin archives bundle `axios@1.16.0`. That version is in the affected range for the newly disclosed Axios inherited-proxy advisory. NemoClaw therefore rebuilds only these two reviewed @@ -93,7 +101,7 @@ archive under `NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS=1`, applies every current NemoClaw patch, verifies syntax, and exercises the live device self-approval proof. This is not a substitute for focused nightly E2E proof. -The `2026.7.1` dist changed two reviewed shapes: +The `2026.7.1` dist changed seven reviewed shapes: - strict managed-proxy activation now uses `isStrictManagedProxyActive`; the patch still activates only inside OpenShell and only without an explicit @@ -105,12 +113,72 @@ The `2026.7.1` dist changed two reviewed shapes: canonical pairing gate can create its pending request. The compatibility patch continues only an exact CLI/operator request limited to `operator.pairing`, `operator.read`, and `operator.write` into that gate; the - requested operation remains blocked until canonical pairing approval. + requested operation remains blocked until canonical pairing approval; +- shared and per-agent SQLite state now run during the required gateway startup + checkpoint and apply owner-only modes on each open. + `scripts/patch-openclaw-shared-state-permissions.mts` keeps the upstream + `0700` directory and `0600` file modes when the OpenShell sandbox marker is + absent. + The image-wide `NEMOCLAW_OPENCLAW_SHARED_STATE=1` marker enables `2770` and + `0660` for direct containers. The legacy `OPENSHELL_SANDBOX=1` marker and + current validated OpenShell sandbox names cover runtimes that sanitize image + environment variables. The separate `sandbox` and `gateway` users can then + access the same database through their shared group. Workers that receive a + narrowed environment inherit these markers from the gateway process. + It skips `chmod` only when the existing mode already matches and rejects an + unexpected or ambiguous compiled-dist shape; +- private file-store writes now reapply owner-only defaults to mutable agent + and identity paths. The same image marker selects setgid `2770` directories and + `0660` files, while preserving OpenClaw's path containment, symbolic-link, + pinned-write, and file-identity checks. An absent marker retains upstream + owner-only modes. Setgid preserves the shared `sandbox` group when either the + CLI or gateway creates a new private-store file; +- generated `models.json` and plugin catalog paths reapply `0600` after the + private file-store write. Under the validated NemoClaw marker, the compiled + models-config patch keeps these files at `0660` and skips a non-owner `chmod` + when the inherited mode is already correct. Outside NemoClaw it preserves + the upstream `0600` behavior; +- the legacy update-check migration is skipped only under the same validated + NemoClaw or OpenShell marker. This state contains polling, notification, and + auto-install cache for an OpenClaw version that NemoClaw pins in the image; + all other startup migrations and the upstream behavior outside NemoClaw are + unchanged. `scripts/patch-openclaw-device-self-approval.mts` remains required. Its new shape recognizers preserve the bounded stored-device credential flow and keep the canonical `approveDevicePairing` transaction fail closed. +## Gateway Startup Migration Compatibility + +OpenClaw `2026.7.1` requires its migration checkpoint to complete without +warnings before the gateway reports readiness. +NemoClaw keeps supported sandbox upgrades compatible with that checkpoint as +follows: + +- the final image copies Node `22.23.1` from the builder, including when the + image layers onto a published base that still contains Node `22.22.2`; +- new images do not seed the legacy `update-check.json` placeholder. + During an upgrade, the descriptor-pinned config helper removes this obsolete + update polling and notification cache whether it is empty or populated when + the entrypoint can mutate the parent. A non-root gateway under the exact + root-owned shields-up topology retains the stable cache because it cannot + unlink it; the patched OpenClaw migration ignores that non-authoritative + pinned-version cache without producing a startup warning. + Without the compatibility patch, OpenClaw would try to harden and archive the + retained cache inside a shields-protected parent. Symbolic links, hard links, + directories, oversized files, or a file that changes during validation are + rejected; +- a root entrypoint starts the `gateway` user with `HOME=/sandbox`, so startup + migrations do not probe the inaccessible `/root/.openclaw` path. + +These repairs run during image build or sandbox startup. +They do not change the documented update and rebuild workflow. +Regression coverage lives in `test/openclaw-2026-7-startup-compat.test.ts` and +`test/openclaw-shared-state-permissions-patch.test.ts`. +Remove the legacy cache repair after every supported upgrade source stops +seeding the file or OpenClaw can migrate it across split users and a protected +parent without a warning. + ## Existing security and runtime contracts The OpenClaw Diagnostics OTEL Host Gateway Boundary remains unchanged. The diff --git a/nemoclaw/package-lock.json b/nemoclaw/package-lock.json index 63d6194e8bc..6fd2a120b58 100644 --- a/nemoclaw/package-lock.json +++ b/nemoclaw/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "execa": "^9.6.1", "json5": "^2.2.3", - "tar": "^7.0.0", + "tar": "^7.5.20", "yaml": "2.8.3" }, "devDependencies": { @@ -1540,9 +1540,9 @@ } }, "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", diff --git a/nemoclaw/package.json b/nemoclaw/package.json index ebc8a5987d5..71bc2b79232 100644 --- a/nemoclaw/package.json +++ b/nemoclaw/package.json @@ -33,7 +33,7 @@ "dependencies": { "execa": "^9.6.1", "json5": "^2.2.3", - "tar": "^7.0.0", + "tar": "^7.5.20", "yaml": "2.8.3" }, "devDependencies": { diff --git a/scripts/lib/normalize_mutable_config_perms.py b/scripts/lib/normalize_mutable_config_perms.py index bd3ba8b548f..988d1c7f336 100755 --- a/scripts/lib/normalize_mutable_config_perms.py +++ b/scripts/lib/normalize_mutable_config_perms.py @@ -7,6 +7,7 @@ from __future__ import annotations import array +import errno import grp import hashlib import os @@ -28,6 +29,7 @@ CONFIG_NAME = "openclaw.json" HASH_NAME = ".config-hash" LAST_GOOD_NAME = "openclaw.json.last-good" +LEGACY_UPDATE_CHECK_NAME = "update-check.json" JSON5_VALIDATOR = r""" const fs = require("fs"); @@ -809,6 +811,177 @@ def cleanup_staged_file( os.close(temp_fd) +def locked_nemoclaw_sealed_tree_matches( + config_dir: str, + parent_fd: int, + parent_metadata: os.stat_result, + root_fd: int, + root_metadata: os.stat_result, + config_name: str, + target_fd: int, + target_metadata: os.stat_result, +) -> bool: + """Recognize the pinned shields-up topology where the cache is immutable.""" + + if os.geteuid() == 0 or not sys.platform.startswith("linux"): + return False + try: + sandbox_gid = grp.getgrnam("sandbox").gr_gid + normalized = os.path.normpath(config_dir) + current_parent = os.stat( + os.path.dirname(normalized), follow_symlinks=False + ) + current_root = os.stat( + config_name, dir_fd=parent_fd, follow_symlinks=False + ) + current_target = os.stat( + LEGACY_UPDATE_CHECK_NAME, + dir_fd=root_fd, + follow_symlinks=False, + ) + current_opened = os.fstat(target_fd) + return ( + stat.S_ISDIR(parent_metadata.st_mode) + and parent_metadata.st_uid == 0 + and parent_metadata.st_gid == sandbox_gid + and stat.S_IMODE(parent_metadata.st_mode) == 0o1775 + and stat.S_ISDIR(root_metadata.st_mode) + and root_metadata.st_uid == 0 + and root_metadata.st_gid == 0 + and stat.S_IMODE(root_metadata.st_mode) == 0o755 + and parent_metadata.st_dev == root_metadata.st_dev + and root_metadata.st_dev == target_metadata.st_dev + and stable_file_key(current_parent) + == stable_file_key(parent_metadata) + and stable_file_key(current_root) == stable_file_key(root_metadata) + and stable_file_key(current_opened) + == stable_file_key(target_metadata) + and stable_file_key(current_target) + == stable_file_key(target_metadata) + and fd_mount_id(parent_fd) == fd_mount_id(root_fd) + and fd_mount_id(root_fd) == fd_mount_id(target_fd) + ) + except (KeyError, OSError, UnsafeTree): + return False + + +def remove_legacy_update_check(config_dir: str) -> int: + """Remove only a stable regular legacy update-check cache file.""" + + parent_fd = -1 + root_fd = -1 + target_fd = -1 + try: + ( + parent_fd, + parent_metadata, + root_fd, + root_metadata, + config_name, + ) = open_config_binding(config_dir) + try: + before = os.stat( + LEGACY_UPDATE_CHECK_NAME, + dir_fd=root_fd, + follow_symlinks=False, + ) + except FileNotFoundError: + return 0 + if ( + not stat.S_ISREG(before.st_mode) + or before.st_dev != root_metadata.st_dev + or before.st_nlink != 1 + or before.st_size > MAX_BASELINE_BYTES + ): + raise UnsafeTree() + + target_fd, opened = open_pinned( + root_fd, LEGACY_UPDATE_CHECK_NAME, file_flags(), before + ) + if ( + stable_file_key(opened) != stable_file_key(before) + or opened.st_size > MAX_BASELINE_BYTES + ): + raise UnsafeTree() + remaining = opened.st_size + while remaining: + chunk = os.read(target_fd, min(1024 * 1024, remaining)) + if not chunk: + raise UnsafeTree() + remaining -= len(chunk) + if os.read(target_fd, 1): + raise UnsafeTree() + + normalized = os.path.normpath(config_dir) + current_parent = os.stat( + os.path.dirname(normalized), follow_symlinks=False + ) + current_root = os.stat(config_name, dir_fd=parent_fd, follow_symlinks=False) + current_target = os.stat( + LEGACY_UPDATE_CHECK_NAME, + dir_fd=root_fd, + follow_symlinks=False, + ) + if ( + inode_key(current_parent) != inode_key(parent_metadata) + or inode_key(current_root) != inode_key(root_metadata) + or stable_file_key(os.fstat(target_fd)) != stable_file_key(opened) + or stable_file_key(current_target) != stable_file_key(opened) + ): + raise UnsafeTree() + + try: + os.unlink(LEGACY_UPDATE_CHECK_NAME, dir_fd=root_fd) + except PermissionError as exc: + if exc.errno not in (errno.EACCES, errno.EPERM) or not ( + locked_nemoclaw_sealed_tree_matches( + config_dir, + parent_fd, + parent_metadata, + root_fd, + root_metadata, + config_name, + target_fd, + opened, + ) + ): + raise + print( + f"[migration] Retained protected legacy " + f"{config_dir}/{LEGACY_UPDATE_CHECK_NAME}; " + "the patched OpenClaw runtime ignores this pinned-version cache", + file=sys.stderr, + ) + return 0 + os.fsync(root_fd) + + try: + os.stat( + LEGACY_UPDATE_CHECK_NAME, + dir_fd=root_fd, + follow_symlinks=False, + ) + except FileNotFoundError: + pass + else: + raise UnsafeTree() + print( + f"[migration] Removed legacy {config_dir}/{LEGACY_UPDATE_CHECK_NAME} " + "before the OpenClaw startup checkpoint", + file=sys.stderr, + ) + return 0 + except (OSError, UnsafeTree): + return 1 + finally: + if target_fd >= 0: + os.close(target_fd) + if root_fd >= 0: + os.close(root_fd) + if parent_fd >= 0: + os.close(parent_fd) + + def recover_empty_config( root_fd: int, config_dir: str, @@ -1631,6 +1804,11 @@ def run_root_supervisor( def main() -> int: + if len(sys.argv) >= 2 and sys.argv[1] == "remove-legacy-update-check": + if len(sys.argv) != 3: + return 1 + return remove_legacy_update_check(sys.argv[2]) + if len(sys.argv) >= 2 and sys.argv[1] == "classify-seal": if len(sys.argv) != 5: return 1 diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index da11047fafc..786b45670bf 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -664,6 +664,32 @@ PY_CLASSIFY_MUTABLE_CONFIG fi } +# OpenClaw 2026.7.1 requires its startup migration checkpoint to complete +# without warnings before the gateway reports readiness. Older NemoClaw images +# persisted update-check.json as update polling and notification cache. Empty +# placeholders fail JSON parsing, while nonempty files cannot be hardened and +# archived by the separate gateway user when shields protect the parent. +# NemoClaw pins OpenClaw in the image, so discard only a descriptor-pinned, +# stable regular cache file before the mandatory checkpoint. +# Remove this repair after every supported upgrade source stops seeding the +# cache or OpenClaw can migrate it across split users and a protected parent. +remove_openclaw_legacy_update_check_state() { + local config_dir="/sandbox/.openclaw" + if [ ! -e "$config_dir" ] && [ ! -L "$config_dir" ]; then + return 0 + fi + + local normalizer + if ! normalizer="$(resolve_mutable_config_normalizer)"; then + printf '[SECURITY] Refusing legacy update-check repair — trusted normalizer is missing\n' >&2 + return 1 + fi + if ! python3 -I "$normalizer" remove-legacy-update-check "$config_dir"; then + printf '[SECURITY] Refusing legacy update-check repair — expected a stable regular file or no file\n' >&2 + return 1 + fi +} + classify_openclaw_config_seal() { local config_dir="$1" local sandbox_uid sandbox_gid @@ -4833,7 +4859,7 @@ launch_openclaw_gateway() { # script -- keeps it in place. arm_openclaw_gateway_supervisor_cleanup mark_in_container_gateway - nohup "${STEP_DOWN_PREFIX_GATEWAY[@]}" sh -c \ + nohup "${STEP_DOWN_PREFIX_GATEWAY[@]}" env HOME=/sandbox sh -c \ 'umask 0007; exec "$@" >>/tmp/gateway.log 2>&1' sh \ "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" & GATEWAY_PID=$! @@ -5356,6 +5382,7 @@ fi # Migrate legacy symlink layout before anything else reads .openclaw migrate_legacy_layout "/sandbox/.openclaw" "/sandbox/.openclaw-data" "openclaw" || exit 1 +remove_openclaw_legacy_update_check_state || exit 1 echo 'Setting up NemoClaw...' >&2 # Best-effort: .env may not exist. diff --git a/scripts/patch-openclaw-shared-state-permissions.mts b/scripts/patch-openclaw-shared-state-permissions.mts new file mode 100644 index 00000000000..0b8670e4c8e --- /dev/null +++ b/scripts/patch-openclaw-shared-state-permissions.mts @@ -0,0 +1,718 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* + * Temporary compatibility patch for OpenClaw 2026.7.1 split-user state. + * + * NemoClaw intentionally runs the OpenClaw CLI and gateway as separate users + * in the same group. OpenClaw 2026.7.1 makes shared and per-agent SQLite state + * and private file stores part of gateway startup, but hardens those paths to + * owner-only modes. Preserve upstream behavior outside NemoClaw, use + * group-shared modes inside its image or an OpenShell sandbox, and ignore only + * the obsolete pinned-version update cache when its migration cannot archive + * through a shields-protected parent. + * + * Remove this patch once upstream supports a group-shared state database for + * split-user containers without requiring a non-owner to chmod an already + * correctly configured file. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_PATH = fileURLToPath(import.meta.url); + +export const MARKER = "/* nemoclaw: group-shared OpenClaw state */"; +export const AGENT_MARKER = "/* nemoclaw: group-shared OpenClaw agent state */"; +export const SECRET_MARKER = "/* nemoclaw: group-shared OpenClaw private store */"; +export const MIGRATION_MARKER = "/* nemoclaw: ignore legacy OpenClaw update-check state */"; +export const FILE_STORE_MARKER = "/* nemoclaw: group-shared OpenClaw file-store defaults */"; +export const MODELS_MARKER = "/* nemoclaw: group-shared OpenClaw models file */"; + +const GROUP_SHARED_ENV_HELPER = [ + "function nemoclawUsesGroupSharedState(env) {", + "\tconst nemoclawSharedStateMarker = env?.NEMOCLAW_OPENCLAW_SHARED_STATE ?? process.env.NEMOCLAW_OPENCLAW_SHARED_STATE;", + "\tconst nemoclawOpenShellMarker = env?.OPENSHELL_SANDBOX ?? process.env.OPENSHELL_SANDBOX;", + '\treturn nemoclawSharedStateMarker === "1" || nemoclawOpenShellMarker === "1" || (typeof nemoclawOpenShellMarker === "string" && /^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(nemoclawOpenShellMarker));', + "}", +].join("\n"); + +const UPSTREAM_MODE_CONSTANTS = [ + "const OPENCLAW_STATE_DIR_MODE = 448;", + "const OPENCLAW_STATE_FILE_MODE = 384;", +].join("\n"); + +const PATCHED_MODE_CONSTANTS = [ + UPSTREAM_MODE_CONSTANTS, + `const NEMOCLAW_SHARED_STATE_DIR_MODE = 0o2770; ${MARKER}`, + "const NEMOCLAW_SHARED_STATE_FILE_MODE = 0o660;", + GROUP_SHARED_ENV_HELPER, +].join("\n"); + +const UPSTREAM_CHMOD_HELPER = [ + "function bestEffortChmodSync(target, mode) {", + "\tconst result = applyPrivateModeSync(target, mode);", + "\tif (result.applied || chmodWarnedTargets.has(target)) return;", + "\tchmodWarnedTargets.add(target);", + "\tstateDbLog.warn(`skipped permission hardening for ${target}: ${String(result.error)}`);", + "}", +].join("\n"); + +const PATCHED_CHMOD_HELPER = [ + "function bestEffortChmodSync(target, mode, skipWhenModeMatches = false) {", + "\tif (skipWhenModeMatches) try {", + "\t\tif ((statSync(target).mode & 0o7777) === mode) return;", + "\t} catch {}", + "\tconst result = applyPrivateModeSync(target, mode);", + "\tif (result.applied || chmodWarnedTargets.has(target)) return;", + "\tchmodWarnedTargets.add(target);", + "\tstateDbLog.warn(`skipped permission hardening for ${target}: ${String(result.error)}`);", + "}", +].join("\n"); + +const UPSTREAM_PERMISSION_HELPER = [ + "function ensureOpenClawStatePermissions(pathname, env) {", + "\tconst dir = path.dirname(pathname);", + "\tconst defaultDir = resolveOpenClawStateSqliteDir(env);", + "\tconst isDefaultStateDatabase = path.resolve(pathname) === path.resolve(resolveOpenClawStateSqlitePath(env));", + "\tif (isDefaultStateDatabase && dir !== defaultDir) throw new Error(`OpenClaw state database path resolved outside its state dir: ${pathname}`);", + "\tconst dirExisted = existsSync(dir);", + "\tmkdirSync(dir, {", + "\t\trecursive: true,", + "\t\tmode: OPENCLAW_STATE_DIR_MODE", + "\t});", + "\tif (isDefaultStateDatabase || !dirExisted) bestEffortChmodSync(dir, OPENCLAW_STATE_DIR_MODE);", + "\tfor (const candidate of resolveSqliteDatabaseFilePaths(pathname)) if (existsSync(candidate)) bestEffortChmodSync(candidate, OPENCLAW_STATE_FILE_MODE);", + "}", +].join("\n"); + +const PATCHED_PERMISSION_HELPER = [ + "function ensureOpenClawStatePermissions(pathname, env) {", + "\tconst dir = path.dirname(pathname);", + "\tconst defaultDir = resolveOpenClawStateSqliteDir(env);", + "\tconst isDefaultStateDatabase = path.resolve(pathname) === path.resolve(resolveOpenClawStateSqlitePath(env));", + "\tif (isDefaultStateDatabase && dir !== defaultDir) throw new Error(`OpenClaw state database path resolved outside its state dir: ${pathname}`);", + "\tconst nemoclawGroupSharedState = nemoclawUsesGroupSharedState(env);", + "\tconst nemoclawStateDirMode = nemoclawGroupSharedState ? NEMOCLAW_SHARED_STATE_DIR_MODE : OPENCLAW_STATE_DIR_MODE;", + "\tconst nemoclawStateFileMode = nemoclawGroupSharedState ? NEMOCLAW_SHARED_STATE_FILE_MODE : OPENCLAW_STATE_FILE_MODE;", + "\tconst dirExisted = existsSync(dir);", + "\tmkdirSync(dir, {", + "\t\trecursive: true,", + "\t\tmode: nemoclawStateDirMode", + "\t});", + "\tif (isDefaultStateDatabase || !dirExisted) bestEffortChmodSync(dir, nemoclawStateDirMode, nemoclawGroupSharedState);", + "\tfor (const candidate of resolveSqliteDatabaseFilePaths(pathname)) if (existsSync(candidate)) bestEffortChmodSync(candidate, nemoclawStateFileMode, nemoclawGroupSharedState);", + "}", +].join("\n"); + +const PATCHED_STATE_REQUIRED_PATTERNS = [ + MARKER, + "const NEMOCLAW_SHARED_STATE_DIR_MODE = 0o2770;", + "const NEMOCLAW_SHARED_STATE_FILE_MODE = 0o660;", + "function nemoclawUsesGroupSharedState(env) {", + "env?.NEMOCLAW_OPENCLAW_SHARED_STATE ?? process.env.NEMOCLAW_OPENCLAW_SHARED_STATE", + "env?.OPENSHELL_SANDBOX ?? process.env.OPENSHELL_SANDBOX", + "function bestEffortChmodSync(target, mode, skipWhenModeMatches = false) {", + "(statSync(target).mode & 0o7777) === mode", + "const nemoclawGroupSharedState = nemoclawUsesGroupSharedState(env);", + "mode: nemoclawStateDirMode", + "bestEffortChmodSync(dir, nemoclawStateDirMode, nemoclawGroupSharedState);", + "bestEffortChmodSync(candidate, nemoclawStateFileMode, nemoclawGroupSharedState);", +] as const; + +const UPSTREAM_AGENT_MODE_CONSTANTS = [ + "const OPENCLAW_AGENT_DB_DIR_MODE = 448;", + "const OPENCLAW_AGENT_DB_FILE_MODE = 384;", +].join("\n"); + +const PATCHED_AGENT_MODE_CONSTANTS = [ + UPSTREAM_AGENT_MODE_CONSTANTS, + `const NEMOCLAW_SHARED_AGENT_DB_DIR_MODE = 0o2770; ${AGENT_MARKER}`, + "const NEMOCLAW_SHARED_AGENT_DB_FILE_MODE = 0o660;", + GROUP_SHARED_ENV_HELPER, +].join("\n"); + +const UPSTREAM_AGENT_PERMISSION_HELPER = [ + "function ensureOpenClawAgentDatabasePermissions(pathname, options) {", + "\tconst dir = path.dirname(pathname);", + "\tconst defaultPath = resolveOpenClawAgentSqlitePath({", + "\t\tagentId: options.agentId,", + "\t\tenv: options.env", + "\t});", + "\tconst isDefaultAgentDatabase = path.resolve(pathname) === path.resolve(defaultPath);", + "\tconst dirExisted = existsSync(dir);", + "\tmkdirSync(dir, {", + "\t\trecursive: true,", + "\t\tmode: OPENCLAW_AGENT_DB_DIR_MODE", + "\t});", + "\tif (isDefaultAgentDatabase || !dirExisted) chmodSync(dir, OPENCLAW_AGENT_DB_DIR_MODE);", + "\tfor (const candidate of resolveSqliteDatabaseFilePaths(pathname)) if (existsSync(candidate)) chmodSync(candidate, OPENCLAW_AGENT_DB_FILE_MODE);", + "}", +].join("\n"); + +const PATCHED_AGENT_PERMISSION_HELPER = [ + "function ensureOpenClawAgentDatabasePermissions(pathname, options) {", + "\tconst dir = path.dirname(pathname);", + "\tconst defaultPath = resolveOpenClawAgentSqlitePath({", + "\t\tagentId: options.agentId,", + "\t\tenv: options.env", + "\t});", + "\tconst isDefaultAgentDatabase = path.resolve(pathname) === path.resolve(defaultPath);", + "\tconst nemoclawGroupSharedState = nemoclawUsesGroupSharedState(options.env);", + "\tconst nemoclawAgentDirMode = nemoclawGroupSharedState ? NEMOCLAW_SHARED_AGENT_DB_DIR_MODE : OPENCLAW_AGENT_DB_DIR_MODE;", + "\tconst nemoclawAgentFileMode = nemoclawGroupSharedState ? NEMOCLAW_SHARED_AGENT_DB_FILE_MODE : OPENCLAW_AGENT_DB_FILE_MODE;", + "\tconst dirExisted = existsSync(dir);", + "\tmkdirSync(dir, {", + "\t\trecursive: true,", + "\t\tmode: nemoclawAgentDirMode", + "\t});", + "\tif ((isDefaultAgentDatabase || !dirExisted) && (!nemoclawGroupSharedState || (statSync(dir).mode & 0o7777) !== nemoclawAgentDirMode)) chmodSync(dir, nemoclawAgentDirMode);", + "\tfor (const candidate of resolveSqliteDatabaseFilePaths(pathname)) if (existsSync(candidate) && (!nemoclawGroupSharedState || (statSync(candidate).mode & 0o7777) !== nemoclawAgentFileMode)) chmodSync(candidate, nemoclawAgentFileMode);", + "}", +].join("\n"); + +const PATCHED_AGENT_REQUIRED_PATTERNS = [ + AGENT_MARKER, + "const NEMOCLAW_SHARED_AGENT_DB_DIR_MODE = 0o2770;", + "const NEMOCLAW_SHARED_AGENT_DB_FILE_MODE = 0o660;", + "function nemoclawUsesGroupSharedState(env) {", + "const nemoclawGroupSharedState = nemoclawUsesGroupSharedState(options.env);", + "mode: nemoclawAgentDirMode", + "(statSync(dir).mode & 0o7777) !== nemoclawAgentDirMode", + "(statSync(candidate).mode & 0o7777) !== nemoclawAgentFileMode", +] as const; + +const UPSTREAM_SECRET_MODE_CONSTANTS = [ + "const PRIVATE_SECRET_DIR_MODE = 448;", + "const PRIVATE_SECRET_FILE_MODE = 384;", +].join("\n"); + +const PATCHED_SECRET_MODE_CONSTANTS = [ + `const NEMOCLAW_SHARED_SECRET_DIR_MODE = 0o2770; ${SECRET_MARKER}`, + "const NEMOCLAW_SHARED_SECRET_FILE_MODE = 0o660;", + GROUP_SHARED_ENV_HELPER, + "const PRIVATE_SECRET_DIR_MODE = nemoclawUsesGroupSharedState() ? NEMOCLAW_SHARED_SECRET_DIR_MODE : 448;", + "const PRIVATE_SECRET_FILE_MODE = nemoclawUsesGroupSharedState() ? NEMOCLAW_SHARED_SECRET_FILE_MODE : 384;", +].join("\n"); + +const UPSTREAM_SECRET_PATH_MODE_HELPER = [ + "async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {", + '\tif (process.platform === "win32") return;', + "\tawait fs$1.chmod(resolvedPath, expectedMode);", + "\tconst actualMode = (await fs$1.stat(resolvedPath)).mode & 511;", + "\tif (actualMode !== expectedMode) throw new Error(`Private secret ${kind} ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);", + "}", +].join("\n"); + +const PATCHED_SECRET_PATH_MODE_HELPER = [ + "async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {", + '\tif (process.platform === "win32") return;', + "\tconst nemoclawGroupSharedState = nemoclawUsesGroupSharedState();", + "\tconst nemoclawModeMask = nemoclawGroupSharedState ? 0o7777 : 511;", + "\tif (nemoclawGroupSharedState && ((await fs$1.stat(resolvedPath)).mode & nemoclawModeMask) === expectedMode) return;", + "\tawait fs$1.chmod(resolvedPath, expectedMode);", + "\tconst actualMode = (await fs$1.stat(resolvedPath)).mode & nemoclawModeMask;", + "\tif (actualMode !== expectedMode) throw new Error(`Private secret ${kind} ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);", + "}", +].join("\n"); + +const UPSTREAM_SECRET_WRITE_DEFAULTS = [ + "async function writeSecretFileAtomic(params) {", + "\tconst mode = params.mode ?? 384;", + "\tconst dirMode = params.dirMode ?? 448;", +].join("\n"); + +const PATCHED_SECRET_WRITE_DEFAULTS = [ + "async function writeSecretFileAtomic(params) {", + "\tconst mode = params.mode ?? PRIVATE_SECRET_FILE_MODE;", + "\tconst dirMode = params.dirMode ?? PRIVATE_SECRET_DIR_MODE;", +].join("\n"); + +const PATCHED_SECRET_REQUIRED_PATTERNS = [ + SECRET_MARKER, + "const NEMOCLAW_SHARED_SECRET_DIR_MODE = 0o2770;", + "const NEMOCLAW_SHARED_SECRET_FILE_MODE = 0o660;", + "const PRIVATE_SECRET_DIR_MODE = nemoclawUsesGroupSharedState()", + "const PRIVATE_SECRET_FILE_MODE = nemoclawUsesGroupSharedState()", + "const nemoclawModeMask = nemoclawGroupSharedState ? 0o7777 : 511;", + "nemoclawGroupSharedState && ((await fs$1.stat(resolvedPath)).mode & nemoclawModeMask) === expectedMode", + "const actualMode = (await fs$1.stat(resolvedPath)).mode & nemoclawModeMask;", + "const mode = params.mode ?? PRIVATE_SECRET_FILE_MODE;", + "const dirMode = params.dirMode ?? PRIVATE_SECRET_DIR_MODE;", +] as const; + +const UPSTREAM_MIGRATION_FUNCTION_START = [ + "function migrateLegacyUpdateCheckState(params) {", + "\tconst changes = [];", + "\tconst warnings = [];", +].join("\n"); + +const UPSTREAM_MIGRATION_START = [ + UPSTREAM_MIGRATION_FUNCTION_START, + "\tif (!fileExists(params.detected.sourcePath)) return {", +].join("\n"); + +const PATCHED_MIGRATION_START = [ + GROUP_SHARED_ENV_HELPER, + UPSTREAM_MIGRATION_FUNCTION_START, + `\tif (nemoclawUsesGroupSharedState()) return { changes, warnings }; ${MIGRATION_MARKER}`, + "\tif (!fileExists(params.detected.sourcePath)) return {", +].join("\n"); + +const PATCHED_MIGRATION_REQUIRED_PATTERNS = [ + MIGRATION_MARKER, + "function nemoclawUsesGroupSharedState(env) {", + "env?.NEMOCLAW_OPENCLAW_SHARED_STATE ?? process.env.NEMOCLAW_OPENCLAW_SHARED_STATE", + "env?.OPENSHELL_SANDBOX ?? process.env.OPENSHELL_SANDBOX", + "function migrateLegacyUpdateCheckState(params) {", + "if (nemoclawUsesGroupSharedState()) return { changes, warnings };", +] as const; + +const UPSTREAM_FILE_STORE_START = [ + "function fileStore(options) {", + "\tconst rootDir = path.resolve(options.rootDir);", + "\tconst privateMode = options.private ?? false;", + "\tconst dirMode = options.dirMode ?? 448;", + "\tconst mode = options.mode ?? 384;", +].join("\n"); + +const PATCHED_FILE_STORE_START = [ + GROUP_SHARED_ENV_HELPER, + `function fileStore(options) { ${FILE_STORE_MARKER}`, + "\tconst rootDir = path.resolve(options.rootDir);", + "\tconst privateMode = options.private ?? false;", + "\tconst nemoclawGroupSharedPrivateStore = privateMode && nemoclawUsesGroupSharedState();", + "\tconst dirMode = options.dirMode ?? (nemoclawGroupSharedPrivateStore ? 0o2770 : 448);", + "\tconst mode = options.mode ?? (nemoclawGroupSharedPrivateStore ? 0o660 : 384);", +].join("\n"); + +const UPSTREAM_FILE_STORE_SYNC_START = [ + "function fileStoreSync(options) {", + "\tconst rootDir = path.resolve(options.rootDir);", + "\tconst privateMode = options.private ?? false;", + "\tconst dirMode = options.dirMode ?? 448;", + "\tconst mode = options.mode ?? 384;", +].join("\n"); + +const PATCHED_FILE_STORE_SYNC_START = [ + "function fileStoreSync(options) {", + "\tconst rootDir = path.resolve(options.rootDir);", + "\tconst privateMode = options.private ?? false;", + "\tconst nemoclawGroupSharedPrivateStore = privateMode && nemoclawUsesGroupSharedState();", + "\tconst dirMode = options.dirMode ?? (nemoclawGroupSharedPrivateStore ? 0o2770 : 448);", + "\tconst mode = options.mode ?? (nemoclawGroupSharedPrivateStore ? 0o660 : 384);", +].join("\n"); + +const PATCHED_FILE_STORE_REQUIRED_PATTERNS = [ + FILE_STORE_MARKER, + "function nemoclawUsesGroupSharedState(env) {", + "function fileStore(options) {", + "function fileStoreSync(options) {", + "const nemoclawGroupSharedPrivateStore = privateMode && nemoclawUsesGroupSharedState();", + "const dirMode = options.dirMode ?? (nemoclawGroupSharedPrivateStore ? 0o2770 : 448);", + "const mode = options.mode ?? (nemoclawGroupSharedPrivateStore ? 0o660 : 384);", +] as const; + +const UPSTREAM_MODELS_FILE_MODE_HELPER = [ + "async function ensureModelsFileModeForModelsJson(pathname) {", + "\tawait fs.chmod(pathname, 384).catch(() => {});", + "}", +].join("\n"); + +const PATCHED_MODELS_FILE_MODE_HELPER = [ + GROUP_SHARED_ENV_HELPER, + `async function ensureModelsFileModeForModelsJson(pathname) { ${MODELS_MARKER}`, + "\tconst nemoclawGroupSharedState = nemoclawUsesGroupSharedState();", + "\tconst nemoclawModelsFileMode = nemoclawGroupSharedState ? 0o660 : 384;", + "\tif (nemoclawGroupSharedState) try {", + "\t\tif (((await fs.stat(pathname)).mode & 0o7777) === nemoclawModelsFileMode) return;", + "\t} catch {}", + "\tawait fs.chmod(pathname, nemoclawModelsFileMode).catch(() => {});", + "}", +].join("\n"); + +const PATCHED_MODELS_REQUIRED_PATTERNS = [ + MODELS_MARKER, + "function nemoclawUsesGroupSharedState(env) {", + "env?.NEMOCLAW_OPENCLAW_SHARED_STATE ?? process.env.NEMOCLAW_OPENCLAW_SHARED_STATE", + "env?.OPENSHELL_SANDBOX ?? process.env.OPENSHELL_SANDBOX", + "async function ensureModelsFileModeForModelsJson(pathname) {", + "const nemoclawModelsFileMode = nemoclawGroupSharedState ? 0o660 : 384;", + "((await fs.stat(pathname)).mode & 0o7777) === nemoclawModelsFileMode", + "await fs.chmod(pathname, nemoclawModelsFileMode).catch(() => {});", +] as const; + +type PatchStatus = "patched" | "already-patched"; + +export interface PatchTextResult { + readonly patched: boolean; + readonly status: PatchStatus; + readonly text: string; +} + +export interface PatchDistResult { + readonly files: readonly string[]; + readonly patched: boolean; + readonly status: PatchStatus; +} + +function usage(): string { + return "Usage: patch-openclaw-shared-state-permissions.mts "; +} + +function countOccurrences(source: string, needle: string): number { + let count = 0; + let offset = source.indexOf(needle); + while (offset !== -1) { + count += 1; + offset = source.indexOf(needle, offset + needle.length); + } + return count; +} + +function requireExactlyOnce(source: string, needle: string, label: string, file: string): void { + const count = countOccurrences(source, needle); + if (count !== 1) { + throw new Error(`${file}: expected exactly one ${label}, found ${count}`); + } +} + +function validatePatchedStateText(source: string, file: string): void { + for (const pattern of PATCHED_STATE_REQUIRED_PATTERNS) { + requireExactlyOnce(source, pattern, `patched pattern ${JSON.stringify(pattern)}`, file); + } + if (source.includes(UPSTREAM_CHMOD_HELPER) || source.includes(UPSTREAM_PERMISSION_HELPER)) { + throw new Error(`${file}: patch marker is present but an upstream permission target remains`); + } +} + +export function patchOpenClawStateDbText(source: string, file: string): PatchTextResult { + if (source.includes(MARKER)) { + validatePatchedStateText(source, file); + return { patched: false, status: "already-patched", text: source }; + } + + requireExactlyOnce(source, UPSTREAM_MODE_CONSTANTS, "state mode constants", file); + requireExactlyOnce(source, UPSTREAM_CHMOD_HELPER, "chmod helper", file); + requireExactlyOnce(source, UPSTREAM_PERMISSION_HELPER, "state permission helper", file); + + const text = source + .replace(UPSTREAM_MODE_CONSTANTS, PATCHED_MODE_CONSTANTS) + .replace(UPSTREAM_CHMOD_HELPER, PATCHED_CHMOD_HELPER) + .replace(UPSTREAM_PERMISSION_HELPER, PATCHED_PERMISSION_HELPER); + validatePatchedStateText(text, file); + return { patched: true, status: "patched", text }; +} + +function validatePatchedAgentText(source: string, file: string): void { + for (const pattern of PATCHED_AGENT_REQUIRED_PATTERNS) { + requireExactlyOnce(source, pattern, `patched pattern ${JSON.stringify(pattern)}`, file); + } + if (source.includes(UPSTREAM_AGENT_PERMISSION_HELPER)) { + throw new Error(`${file}: patch marker is present but an upstream permission target remains`); + } +} + +export function patchOpenClawAgentDbText(source: string, file: string): PatchTextResult { + if (source.includes(AGENT_MARKER)) { + validatePatchedAgentText(source, file); + return { patched: false, status: "already-patched", text: source }; + } + + requireExactlyOnce(source, UPSTREAM_AGENT_MODE_CONSTANTS, "agent state mode constants", file); + requireExactlyOnce( + source, + UPSTREAM_AGENT_PERMISSION_HELPER, + "agent state permission helper", + file, + ); + const text = source + .replace(UPSTREAM_AGENT_MODE_CONSTANTS, PATCHED_AGENT_MODE_CONSTANTS) + .replace(UPSTREAM_AGENT_PERMISSION_HELPER, PATCHED_AGENT_PERMISSION_HELPER); + validatePatchedAgentText(text, file); + return { patched: true, status: "patched", text }; +} + +function validatePatchedSecretText(source: string, file: string): void { + for (const pattern of PATCHED_SECRET_REQUIRED_PATTERNS) { + requireExactlyOnce(source, pattern, `patched pattern ${JSON.stringify(pattern)}`, file); + } + for (const upstreamTarget of [ + UPSTREAM_SECRET_MODE_CONSTANTS, + UPSTREAM_SECRET_PATH_MODE_HELPER, + UPSTREAM_SECRET_WRITE_DEFAULTS, + ]) { + if (source.includes(upstreamTarget)) { + throw new Error( + `${file}: patch marker is present but an upstream private-store target remains`, + ); + } + } +} + +export function patchOpenClawSecretFileText(source: string, file: string): PatchTextResult { + if (source.includes(SECRET_MARKER)) { + validatePatchedSecretText(source, file); + return { patched: false, status: "already-patched", text: source }; + } + + requireExactlyOnce(source, UPSTREAM_SECRET_MODE_CONSTANTS, "private-store mode constants", file); + requireExactlyOnce( + source, + UPSTREAM_SECRET_PATH_MODE_HELPER, + "private-store path mode helper", + file, + ); + requireExactlyOnce(source, UPSTREAM_SECRET_WRITE_DEFAULTS, "private-store write defaults", file); + const text = source + .replace(UPSTREAM_SECRET_MODE_CONSTANTS, PATCHED_SECRET_MODE_CONSTANTS) + .replace(UPSTREAM_SECRET_PATH_MODE_HELPER, PATCHED_SECRET_PATH_MODE_HELPER) + .replace(UPSTREAM_SECRET_WRITE_DEFAULTS, PATCHED_SECRET_WRITE_DEFAULTS); + validatePatchedSecretText(text, file); + return { patched: true, status: "patched", text }; +} + +function validatePatchedMigrationText(source: string, file: string): void { + for (const pattern of PATCHED_MIGRATION_REQUIRED_PATTERNS) { + requireExactlyOnce(source, pattern, `patched pattern ${JSON.stringify(pattern)}`, file); + } + if (source.includes(UPSTREAM_MIGRATION_START)) { + throw new Error(`${file}: patch marker is present but the upstream migration target remains`); + } +} + +export function patchOpenClawStateMigrationText(source: string, file: string): PatchTextResult { + if (source.includes(MIGRATION_MARKER)) { + validatePatchedMigrationText(source, file); + return { patched: false, status: "already-patched", text: source }; + } + + requireExactlyOnce(source, UPSTREAM_MIGRATION_START, "legacy update-check migration start", file); + const text = source.replace(UPSTREAM_MIGRATION_START, PATCHED_MIGRATION_START); + validatePatchedMigrationText(text, file); + return { patched: true, status: "patched", text }; +} + +function validatePatchedFileStoreText(source: string, file: string): void { + for (const pattern of PATCHED_FILE_STORE_REQUIRED_PATTERNS) { + const expectedCount = pattern.startsWith("const ") ? 2 : 1; + const count = countOccurrences(source, pattern); + if (count !== expectedCount) { + throw new Error( + `${file}: expected exactly ${expectedCount === 1 ? "one" : expectedCount} patched pattern ${JSON.stringify(pattern)}, found ${count}`, + ); + } + } + if ( + source.includes(UPSTREAM_FILE_STORE_START) || + source.includes(UPSTREAM_FILE_STORE_SYNC_START) + ) { + throw new Error(`${file}: patch marker is present but an upstream file-store target remains`); + } +} + +export function patchOpenClawFileStoreText(source: string, file: string): PatchTextResult { + if (source.includes(FILE_STORE_MARKER)) { + validatePatchedFileStoreText(source, file); + return { patched: false, status: "already-patched", text: source }; + } + + requireExactlyOnce(source, UPSTREAM_FILE_STORE_START, "async file-store defaults", file); + requireExactlyOnce(source, UPSTREAM_FILE_STORE_SYNC_START, "sync file-store defaults", file); + const text = source + .replace(UPSTREAM_FILE_STORE_START, PATCHED_FILE_STORE_START) + .replace(UPSTREAM_FILE_STORE_SYNC_START, PATCHED_FILE_STORE_SYNC_START); + validatePatchedFileStoreText(text, file); + return { patched: true, status: "patched", text }; +} + +function validatePatchedModelsText(source: string, file: string): void { + for (const pattern of PATCHED_MODELS_REQUIRED_PATTERNS) { + requireExactlyOnce(source, pattern, `patched pattern ${JSON.stringify(pattern)}`, file); + } + if (source.includes(UPSTREAM_MODELS_FILE_MODE_HELPER)) { + throw new Error(`${file}: patch marker is present but the upstream models mode target remains`); + } +} + +export function patchOpenClawModelsConfigText(source: string, file: string): PatchTextResult { + if (source.includes(MODELS_MARKER)) { + validatePatchedModelsText(source, file); + return { patched: false, status: "already-patched", text: source }; + } + + requireExactlyOnce(source, UPSTREAM_MODELS_FILE_MODE_HELPER, "models file mode helper", file); + const text = source.replace(UPSTREAM_MODELS_FILE_MODE_HELPER, PATCHED_MODELS_FILE_MODE_HELPER); + validatePatchedModelsText(text, file); + return { patched: true, status: "patched", text }; +} + +function listCandidates(dir: string, filenamePattern: RegExp): string[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + throw new Error( + `Could not read OpenClaw dist directory ${dir}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return entries + .filter((entry) => entry.isFile() && filenamePattern.test(entry.name)) + .map((entry) => path.join(dir, entry.name)) + .sort(); +} + +function isStateDbCandidate(source: string): boolean { + return ( + source.includes(MARKER) || + source.includes("const OPENCLAW_STATE_DIR_MODE = 448;") || + source.includes("function ensureOpenClawStatePermissions(pathname, env) {") + ); +} + +export function patchOpenClawSharedStatePermissions(distDir: string): PatchDistResult { + const resolvedDist = path.resolve(distDir); + const stateCandidates = listCandidates(resolvedDist, /^openclaw-state-db-.+\.js$/).filter( + (file) => isStateDbCandidate(fs.readFileSync(file, "utf8")), + ); + if (stateCandidates.length !== 1) { + throw new Error( + `Expected exactly one OpenClaw shared-state database target in ${resolvedDist}, found ${stateCandidates.length}`, + ); + } + const agentCandidates = listCandidates(resolvedDist, /^openclaw-agent-db-.+\.js$/).filter( + (file) => { + const source = fs.readFileSync(file, "utf8"); + return ( + source.includes(AGENT_MARKER) || + source.includes("const OPENCLAW_AGENT_DB_DIR_MODE = 448;") || + source.includes("function ensureOpenClawAgentDatabasePermissions(pathname, options) {") + ); + }, + ); + if (agentCandidates.length !== 1) { + throw new Error( + `Expected exactly one OpenClaw per-agent database target in ${resolvedDist}, found ${agentCandidates.length}`, + ); + } + const secretCandidates = listCandidates(resolvedDist, /^secret-file-.+\.js$/).filter((file) => { + const source = fs.readFileSync(file, "utf8"); + return ( + source.includes(SECRET_MARKER) || + source.includes("const PRIVATE_SECRET_DIR_MODE = 448;") || + source.includes("async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {") + ); + }); + if (secretCandidates.length !== 1) { + throw new Error( + `Expected exactly one OpenClaw private-store target in ${resolvedDist}, found ${secretCandidates.length}`, + ); + } + const migrationCandidates = listCandidates(resolvedDist, /^state-migrations-.+\.js$/).filter( + (file) => { + const source = fs.readFileSync(file, "utf8"); + return ( + source.includes(MIGRATION_MARKER) || + source.includes("function migrateLegacyUpdateCheckState(params) {") + ); + }, + ); + if (migrationCandidates.length !== 1) { + throw new Error( + `Expected exactly one OpenClaw state-migration target in ${resolvedDist}, found ${migrationCandidates.length}`, + ); + } + const fileStoreCandidates = listCandidates(resolvedDist, /^file-store-.+\.js$/).filter((file) => { + const source = fs.readFileSync(file, "utf8"); + return ( + source.includes(FILE_STORE_MARKER) || + (source.includes("function fileStore(options) {") && + source.includes("function fileStoreSync(options) {")) + ); + }); + if (fileStoreCandidates.length !== 1) { + throw new Error( + `Expected exactly one OpenClaw file-store target in ${resolvedDist}, found ${fileStoreCandidates.length}`, + ); + } + const modelsCandidates = listCandidates(resolvedDist, /^models-config-.+\.js$/).filter((file) => { + const source = fs.readFileSync(file, "utf8"); + return ( + source.includes(MODELS_MARKER) || + (source.includes("async function ensureModelsFileModeForModelsJson(pathname) {") && + source.includes( + "async function writeModelsFileAtomicForModelsJson(targetPath, contents) {", + )) + ); + }); + if (modelsCandidates.length !== 1) { + throw new Error( + `Expected exactly one OpenClaw models-config target in ${resolvedDist}, found ${modelsCandidates.length}`, + ); + } + + const stateFile = stateCandidates[0]; + const agentFile = agentCandidates[0]; + const secretFile = secretCandidates[0]; + const migrationFile = migrationCandidates[0]; + const fileStoreFile = fileStoreCandidates[0]; + const modelsFile = modelsCandidates[0]; + const stateResult = patchOpenClawStateDbText(fs.readFileSync(stateFile, "utf8"), stateFile); + const agentResult = patchOpenClawAgentDbText(fs.readFileSync(agentFile, "utf8"), agentFile); + const secretResult = patchOpenClawSecretFileText(fs.readFileSync(secretFile, "utf8"), secretFile); + const migrationResult = patchOpenClawStateMigrationText( + fs.readFileSync(migrationFile, "utf8"), + migrationFile, + ); + const fileStoreResult = patchOpenClawFileStoreText( + fs.readFileSync(fileStoreFile, "utf8"), + fileStoreFile, + ); + const modelsResult = patchOpenClawModelsConfigText( + fs.readFileSync(modelsFile, "utf8"), + modelsFile, + ); + if (stateResult.patched) fs.writeFileSync(stateFile, stateResult.text); + if (agentResult.patched) fs.writeFileSync(agentFile, agentResult.text); + if (secretResult.patched) fs.writeFileSync(secretFile, secretResult.text); + if (migrationResult.patched) fs.writeFileSync(migrationFile, migrationResult.text); + if (fileStoreResult.patched) fs.writeFileSync(fileStoreFile, fileStoreResult.text); + if (modelsResult.patched) fs.writeFileSync(modelsFile, modelsResult.text); + const patched = + stateResult.patched || + agentResult.patched || + secretResult.patched || + migrationResult.patched || + fileStoreResult.patched || + modelsResult.patched; + return { + files: [stateFile, agentFile, secretFile, migrationFile, fileStoreFile, modelsFile], + patched, + status: patched ? "patched" : "already-patched", + }; +} + +function main(argv: readonly string[]): number { + const distDir = argv[2]; + if (!distDir || argv.length > 3) { + console.error(usage()); + return 2; + } + try { + const result = patchOpenClawSharedStatePermissions(distDir); + console.log( + `INFO: OpenClaw SQLite state permissions ${result.status}: ${result.files.map((file) => path.basename(file)).join(", ")}`, + ); + return 0; + } catch (err) { + console.error(`ERROR: ${err instanceof Error ? err.message : String(err)}`); + return 1; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH) { + process.exitCode = main(process.argv); +} diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index 964944f1d29..4d02e90ee42 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -34,12 +34,12 @@ const EXACT_CUSTOM_POST_GENERATOR_RUN_RE = [ // A lifecycle test verifies these digests against the checked-in Dockerfile. const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "e7256f12c618bb424f53fec801378d92446d880c5935965ebb3b548694866b63", - "862807dd20a2879f49862a7d9d02fbdc2aa1be00539d05c86814b23f451b4a29", + "e12f955570ab3111ba6b1fed46149abaaabba42268e79eb9e42ba1d9f455db6e", "737edaaa69f80cf10d42fd349e0be068c1ef6e7375d5dcb4055b012420b58736", "5b814e92449a6778385f588877fe72ebed80e601f8eb0c90c2842b17a489f3da", "0e1a9a7bab2fab0a974577c3af8785157b4b9be2b4db32d5f4f9e5aa3c8c8171", "a68297161e2c6463440b822f4e4be0518e745fb5fba8c61ab53b876724f7b666", - "865a9e486e1f0f54e33138a94d5cf51feb67daec4b6e6f0e21f9de22ef7e10f7", + "a54e2ac58ef00d7080ad697cb1892bf91b7bffe011f698df17b936c9906cd4af", "ca493ae7905fae5c587a8e5c31fcb3d423235940589c2decee99d7b338e87d88", "d181ff3c36d8982f78b5627d1f4a02fd30d2667cd1ca8ffb97fb65535ae452ee", "6d4094a9d7c21eeb408cadd728da7cd7e0ee9574746436be59c26b218c8ab218", diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 4ee4673d8ed..5b8e2202595 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -252,6 +252,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "patch-openclaw-device-self-approval.mts"), path.join(stagedScriptsDir, "patch-openclaw-device-self-approval.mts"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "patch-openclaw-shared-state-permissions.mts"), + path.join(stagedScriptsDir, "patch-openclaw-shared-state-permissions.mts"), + ); fs.copyFileSync( path.join(rootDir, "scripts", "verify-wechat-runtime-lock.mts"), path.join(stagedScriptsDir, "verify-wechat-runtime-lock.mts"), diff --git a/test/helpers/openclaw-real-device-self-approval-proof.ts b/test/helpers/openclaw-real-device-self-approval-proof.ts index 2ba70b72e21..57e1cb2352b 100644 --- a/test/helpers/openclaw-real-device-self-approval-proof.ts +++ b/test/helpers/openclaw-real-device-self-approval-proof.ts @@ -10,6 +10,7 @@ import { pathToFileURL } from "node:url"; interface ProofOptions { dist: string; + nodeExecutable: string; patchScript: string; timeoutMs: number; tmp: string; @@ -520,7 +521,7 @@ function runPairingCrashDirectionProof( const durablePath = durableSide === "pending" ? fixture.pendingPath : fixture.pairedPath; const interruptedPath = durableSide === "pending" ? fixture.pairedPath : fixture.pendingPath; const crash = spawnSync( - process.execPath, + options.nodeExecutable, [ "--input-type=module", "-e", @@ -620,7 +621,7 @@ throw new Error("injected crash did not terminate the process"); ); const restart = spawnSync( - process.execPath, + options.nodeExecutable, [ "--input-type=module", "-e", @@ -672,7 +673,7 @@ if (JSON.stringify(first) !== JSON.stringify(second)) throw new Error("second re requireIdlePairingJournal(fixture.journalPath, `real-dist ${durableSide}-first rollback journal`); const retry = spawnSync( - process.execPath, + options.nodeExecutable, [ "--input-type=module", "-e", @@ -724,7 +725,7 @@ function runRejectedRenameRollbackProof( ): void { const fixture = createPairingTransactionFixture(options.tmp, "rejected-rename", journalBasename); const proof = spawnSync( - process.execPath, + options.nodeExecutable, [ "--input-type=module", "-e", @@ -939,7 +940,7 @@ async function runLiveStoredDeviceAuthSelfApprovalProof(options: ProofOptions): OPENCLAW_STATE_DIR: stateDir, }; const runCli = (args: string[]) => - spawnSync(process.execPath, [openclawEntry, ...args], { + spawnSync(options.nodeExecutable, [openclawEntry, ...args], { cwd: packageDir, encoding: "utf8", env, @@ -948,7 +949,7 @@ async function runLiveStoredDeviceAuthSelfApprovalProof(options: ProofOptions): const startGateway = (gatewayEnv: NodeJS.ProcessEnv, append: boolean) => { const gatewayLogFd = fs.openSync(gatewayLog, append ? "a" : "w"); - const child = spawn(process.execPath, [openclawEntry, "gateway", "run"], { + const child = spawn(options.nodeExecutable, [openclawEntry, "gateway", "run"], { cwd: packageDir, env: gatewayEnv, stdio: ["ignore", gatewayLogFd, gatewayLogFd], @@ -1127,7 +1128,7 @@ async function runLiveStoredDeviceAuthSelfApprovalProof(options: ProofOptions): export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptions): Promise { const patch = spawnSync( - process.execPath, + options.nodeExecutable, ["--experimental-strip-types", options.patchScript, options.dist], { encoding: "utf8", @@ -1142,7 +1143,7 @@ export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptio ); const audit = spawnSync( - process.execPath, + options.nodeExecutable, ["--experimental-strip-types", options.patchScript, "--audit", options.dist], { encoding: "utf8", @@ -1307,7 +1308,7 @@ export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptio } const deviceBootstrapUrl = pathToFileURL(deviceBootstrapFile).href; const runtimeProof = spawnSync( - process.execPath, + options.nodeExecutable, [ "--input-type=module", "-e", diff --git a/test/openclaw-2026-7-startup-compat.test.ts b/test/openclaw-2026-7-startup-compat.test.ts new file mode 100644 index 00000000000..a8e7aa5c2ad --- /dev/null +++ b/test/openclaw-2026-7-startup-compat.test.ts @@ -0,0 +1,187 @@ +// 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.resolve(import.meta.dirname, ".."); +const NORMALIZER = path.join(ROOT, "scripts", "lib", "normalize_mutable_config_perms.py"); +const START_SCRIPT = path.join(ROOT, "scripts", "nemoclaw-start.sh"); +const DOCKERFILE = path.join(ROOT, "Dockerfile"); +const temporaryRoots: string[] = []; + +function temporaryConfigDir(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-2026-7-")); + temporaryRoots.push(root); + const configDir = path.join(root, ".openclaw"); + fs.mkdirSync(configDir); + return configDir; +} + +function repairUpdateCheck(configDir: string) { + return spawnSync("python3", ["-I", NORMALIZER, "remove-legacy-update-check", configDir], { + encoding: "utf-8", + timeout: 5000, + }); +} + +function extractShellFunction(source: string, name: string): string { + const match = source.match(new RegExp(`${name}\\(\\) \\{([\\s\\S]*?)^\\}`, "m")); + if (!match) throw new Error(`Expected ${name} in scripts/nemoclaw-start.sh`); + return `${name}() {${match[1]}\n}`; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("OpenClaw 2026.7 startup compatibility", () => { + it("marks direct NemoClaw containers for split-user shared state", () => { + expect(fs.readFileSync(DOCKERFILE, "utf-8")).toContain("ENV NEMOCLAW_OPENCLAW_SHARED_STATE=1"); + }); + + it("removes a zero-byte legacy update-check file", () => { + const configDir = temporaryConfigDir(); + const statePath = path.join(configDir, "update-check.json"); + fs.writeFileSync(statePath, "", { mode: 0o640 }); + const result = repairUpdateCheck(configDir); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(statePath)).toBe(false); + }); + + it("removes nonempty legacy update-check cache state", () => { + const configDir = temporaryConfigDir(); + const statePath = path.join(configDir, "update-check.json"); + const content = '{"lastCheck":123}\n'; + fs.writeFileSync(statePath, content); + + const result = repairUpdateCheck(configDir); + + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(statePath)).toBe(false); + }); + + it.skipIf(process.platform !== "linux" || (process.getuid?.() ?? -1) !== 0)( + "retains but accepts a stable cache under the non-root shields-up topology", + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sealed-cache-")); + temporaryRoots.push(root); + const sandboxDir = path.join(root, "sandbox"); + const configDir = path.join(sandboxDir, ".openclaw"); + const statePath = path.join(configDir, "update-check.json"); + const sandboxGid = 65_534; + const gatewayUid = 65_532; + fs.chmodSync(root, 0o755); + fs.mkdirSync(sandboxDir); + fs.chownSync(sandboxDir, 0, sandboxGid); + fs.chmodSync(sandboxDir, 0o1775); + fs.mkdirSync(configDir); + fs.chownSync(configDir, 0, 0); + fs.chmodSync(configDir, 0o755); + fs.writeFileSync(statePath, '{"lastCheck":123}\n', { mode: 0o644 }); + fs.chownSync(statePath, 0, 0); + + const result = spawnSync( + "python3", + [ + "-I", + "-c", + [ + "import importlib.util, os, sys, types", + "spec = importlib.util.spec_from_file_location('normalizer', sys.argv[1])", + "module = importlib.util.module_from_spec(spec)", + "spec.loader.exec_module(module)", + "sandbox_gid = int(sys.argv[3])", + "module.grp.getgrnam = lambda _name: types.SimpleNamespace(gr_gid=sandbox_gid)", + "os.setgroups([sandbox_gid])", + "os.setgid(sandbox_gid)", + "os.setuid(int(sys.argv[4]))", + "raise SystemExit(module.remove_legacy_update_check(sys.argv[2]))", + ].join("\n"), + NORMALIZER, + configDir, + String(sandboxGid), + String(gatewayUid), + ], + { encoding: "utf8", timeout: 5000 }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("Retained protected legacy"); + expect(fs.readFileSync(statePath, "utf8")).toBe('{"lastCheck":123}\n'); + }, + ); + + it.each([ + "symlink", + "directory", + "hardlink", + ] as const)("rejects a %s update-check path", (kind) => { + const configDir = temporaryConfigDir(); + const statePath = path.join(configDir, "update-check.json"); + if (kind === "symlink") { + const target = path.join(path.dirname(configDir), "target.json"); + fs.writeFileSync(target, ""); + fs.symlinkSync(target, statePath); + } else if (kind === "directory") { + fs.mkdirSync(statePath); + } else { + const target = path.join(path.dirname(configDir), "target.json"); + fs.writeFileSync(target, "{}"); + fs.linkSync(target, statePath); + } + + const result = repairUpdateCheck(configDir); + + expect(result.status).toBe(1); + }); + + it("starts the root-mode gateway with the sandbox home", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-home-")); + temporaryRoots.push(root); + const observedHome = path.join(root, "observed-home"); + const gatewayLog = path.join(root, "gateway.log"); + const gateway = path.join(root, "openclaw-fixture"); + fs.writeFileSync( + gateway, + `#!/usr/bin/env bash\nprintf '%s\\n' "$HOME" >${JSON.stringify(observedHome)}\n`, + { mode: 0o700 }, + ); + const source = fs.readFileSync(START_SCRIPT, "utf-8"); + const launch = extractShellFunction(source, "launch_openclaw_gateway").replaceAll( + "/tmp/gateway.log", + gatewayLog, + ); + const runner = path.join(root, "run.sh"); + fs.writeFileSync( + runner, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "export HOME=/root", + "STEP_DOWN_PREFIX_GATEWAY=()", + `OPENCLAW=${JSON.stringify(gateway)}`, + "_DASHBOARD_PORT=18789", + "arm_openclaw_gateway_supervisor_cleanup() { :; }", + "mark_in_container_gateway() { :; }", + "capture_openclaw_pid_start_identity() { printf -v \"$2\" '%s' test-identity; }", + "record_gateway_pid() { :; }", + launch, + "launch_openclaw_gateway", + 'wait "$GATEWAY_PID"', + ].join("\n"), + { mode: 0o700 }, + ); + + const result = spawnSync("bash", [runner], { encoding: "utf-8", timeout: 5000 }); + + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(observedHome, "utf-8").trim()).toBe("/sandbox"); + }); +}); diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index dde2e16a043..fb9ff192fd4 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -38,6 +38,11 @@ const DEVICE_SELF_APPROVAL_PATCH = path.join( "scripts", "patch-openclaw-device-self-approval.mts", ); +const SHARED_STATE_PERMISSIONS_PATCH = path.join( + REPO_ROOT, + "scripts", + "patch-openclaw-shared-state-permissions.mts", +); const REBUILD_RESUME_SESSION = path.join( REPO_ROOT, "src", @@ -422,6 +427,15 @@ check_not_contains "$optional_plugin_block" 'pack_reviewed_npm_tarball' "optiona grep -Fq 'nemoclaw: validate bounded self-approval inside pairing lock' "$device_self_approval_patch" grep -Fq 'COPY scripts/patch-openclaw-device-self-approval.mts /usr/local/lib/nemoclaw/patch-openclaw-device-self-approval.mts' Dockerfile grep -Fq 'node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-device-self-approval.mts \\' Dockerfile + shared_state_permissions_patch=${JSON.stringify(SHARED_STATE_PERMISSIONS_PATCH)} + grep -Fq 'nemoclaw: group-shared OpenClaw state' "$shared_state_permissions_patch" + grep -Fq 'nemoclaw: group-shared OpenClaw agent state' "$shared_state_permissions_patch" + grep -Fq 'nemoclaw: group-shared OpenClaw private store' "$shared_state_permissions_patch" + grep -Fq 'nemoclaw: group-shared OpenClaw file-store defaults' "$shared_state_permissions_patch" + grep -Fq 'nemoclaw: group-shared OpenClaw models file' "$shared_state_permissions_patch" + grep -Fq 'nemoclaw: ignore legacy OpenClaw update-check state' "$shared_state_permissions_patch" + grep -Fq 'COPY scripts/patch-openclaw-shared-state-permissions.mts /usr/local/lib/nemoclaw/patch-openclaw-shared-state-permissions.mts' Dockerfile + grep -Fq 'node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-shared-state-permissions.mts \\' Dockerfile phase_count="$(grep -Ec -- '--phase (runtime-setup|agent-install|post-agent-install)' Dockerfile)" test "$phase_count" -eq 3 diff --git a/test/openclaw-integrity-pin-suite.ts b/test/openclaw-integrity-pin-suite.ts index 69982a50b65..291c92aaa11 100644 --- a/test/openclaw-integrity-pin-suite.ts +++ b/test/openclaw-integrity-pin-suite.ts @@ -44,6 +44,10 @@ const PINNED_OPENCLAW_VERSION = "2026.7.1"; const PINNED_OPENCLAW_INTEGRITY = "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g=="; const PINNED_OPENCLAW_TARBALL = "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz"; +const PINNED_NEMOCLAW_TAR_VERSION = "7.5.20"; +const PINNED_NEMOCLAW_TAR_INTEGRITY = + "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ=="; +const PINNED_NEMOCLAW_TAR_TARBALL = "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz"; const PINNED_CODEX_ACP_VERSION = "0.11.1"; const PINNED_CODEX_ACP_TARBALL = "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.11.1.tgz"; @@ -459,6 +463,9 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(reviewNote).toContain(`openclaw@${PINNED_OPENCLAW_VERSION}`); expect(reviewNote).toContain(PINNED_OPENCLAW_INTEGRITY); expect(reviewNote).toContain(PINNED_OPENCLAW_TARBALL); + expect(reviewNote).toContain(`tar@${PINNED_NEMOCLAW_TAR_VERSION}`); + expect(reviewNote).toContain(PINNED_NEMOCLAW_TAR_INTEGRITY); + expect(reviewNote).toContain(PINNED_NEMOCLAW_TAR_TARBALL); expect(reviewNote).toContain(`@zed-industries/codex-acp@${PINNED_CODEX_ACP_VERSION}`); expect(reviewNote).toContain(PINNED_CODEX_ACP_TARBALL); expect(reviewNote).toContain(PINNED_CODEX_ACP_INTEGRITY); @@ -521,6 +528,9 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(reviewNote).toContain("gateway/upstream reporting layer"); expect(reviewNote).toContain("scripts/patch-openclaw-issue-4434-diagnostics.mts"); expect(reviewNote).toContain("scripts/patch-openclaw-device-self-approval.mts"); + expect(reviewNote).toContain("scripts/patch-openclaw-shared-state-permissions.mts"); + expect(reviewNote).toContain("Gateway Startup Migration Compatibility"); + expect(reviewNote).toContain("HOME=/sandbox"); expect(reviewNote).toContain("approveDevicePairing"); expect(reviewNote).toContain( "Recovery hint: check sandbox egress and provider reachability, then retry.", @@ -528,6 +538,27 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(reviewNote).toContain("default 180-second timeout"); }); + it("keeps NemoClaw's direct tar dependency above the reviewed advisory floor", () => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "nemoclaw", "package.json"), "utf-8"), + ) as { dependencies?: Record }; + const packageLock = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "nemoclaw", "package-lock.json"), "utf-8"), + ) as { + packages?: Record; + }; + const lockedTar = packageLock.packages?.["node_modules/tar"]; + + expect(packageJson.dependencies?.tar).toBe(`^${PINNED_NEMOCLAW_TAR_VERSION}`); + expect(lockedTar).toEqual( + expect.objectContaining({ + integrity: PINNED_NEMOCLAW_TAR_INTEGRITY, + resolved: PINNED_NEMOCLAW_TAR_TARBALL, + version: PINNED_NEMOCLAW_TAR_VERSION, + }), + ); + }); + it("keeps the Teams OpenClaw plugin manifest pinned to the reviewed 2026.7.1 integrity", () => { const teamsManifest = createBuiltInChannelManifestRegistry().get("teams"); const teamsPackage = teamsManifest?.agentPackages?.find( diff --git a/test/openclaw-real-patched-dist-harness.test.ts b/test/openclaw-real-patched-dist-harness.test.ts index 86b6801b605..081d1327d15 100644 --- a/test/openclaw-real-patched-dist-harness.test.ts +++ b/test/openclaw-real-patched-dist-harness.test.ts @@ -19,6 +19,12 @@ const PATCH_OPENCLAW_ISSUE_4434_DIAGNOSTICS = path.join( "scripts", "patch-openclaw-issue-4434-diagnostics.mts", ); +const PATCH_OPENCLAW_SHARED_STATE_PERMISSIONS = path.join( + REPO_ROOT, + "scripts", + "patch-openclaw-shared-state-permissions.mts", +); +const REAL_OPENCLAW_NODE_ENV = "NEMOCLAW_REAL_OPENCLAW_NODE"; // Focused patch scripts also scan the full generated dist. APFS cold-cache // reads can exceed one minute, so keep them bounded without using unit-fixture // timings as the real-artifact limit. @@ -116,6 +122,52 @@ function requireSpawnSuccess( requireRuntimeEqual(String(result.status), "0", detail ? `${label}: ${detail}` : label); } +interface RealOpenClawNodeRuntime { + executable: string; + version: string; +} + +function resolveRealOpenClawNodeRuntime( + env: NodeJS.ProcessEnv = process.env, +): RealOpenClawNodeRuntime { + const configured = env[REAL_OPENCLAW_NODE_ENV]?.trim(); + const executable = configured || process.execPath; + path.isAbsolute(executable) || + runtimeMismatch(executable, "an absolute path", REAL_OPENCLAW_NODE_ENV); + + let executableStat: fs.Stats; + try { + executableStat = fs.statSync(executable); + fs.accessSync(executable, fs.constants.X_OK); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + runtimeMismatch(detail, "an executable regular file", REAL_OPENCLAW_NODE_ENV); + } + executableStat.isFile() || + runtimeMismatch(executable, "an executable regular file", REAL_OPENCLAW_NODE_ENV); + + const versionProbe = spawnSync(executable, ["--version"], { + encoding: "utf-8", + timeout: 10_000, + }); + requireSpawnSuccess(versionProbe, `probe ${REAL_OPENCLAW_NODE_ENV}`); + const version = versionProbe.stdout.trim(); + const match = version.match(/^v(\d+)\.(\d+)\.(\d+)$/u); + if (!match) runtimeMismatch(version, "a stable Node version", REAL_OPENCLAW_NODE_ENV); + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + const supportedNode22 = major === 22 && (minor > 22 || (minor === 22 && patch >= 3)); + supportedNode22 || + runtimeMismatch( + version, + "Node >=22.22.3 <23 (the Dockerfile runtime is Node 22.23.1)", + REAL_OPENCLAW_NODE_ENV, + ); + + return { executable, version }; +} + function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } @@ -220,6 +272,35 @@ function materializeReviewedTarball( } describe("OpenClaw real patched-dist materialization guard", () => { + it("rejects an unsupported explicit real-dist Node runtime before OpenClaw starts", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-node-runtime-")); + try { + const fakeNode = path.join(tmp, "node"); + fs.writeFileSync(fakeNode, "#!/bin/sh\nprintf 'v22.22.2\\n'\n", { mode: 0o700 }); + + expect(() => resolveRealOpenClawNodeRuntime({ [REAL_OPENCLAW_NODE_ENV]: fakeNode })).toThrow( + /Node >=22\.22\.3 <23/, + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("accepts an explicit absolute Node runtime in the reviewed production lane", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-node-runtime-")); + try { + const fakeNode = path.join(tmp, "node"); + fs.writeFileSync(fakeNode, "#!/bin/sh\nprintf 'v22.23.1\\n'\n", { mode: 0o700 }); + + expect(resolveRealOpenClawNodeRuntime({ [REAL_OPENCLAW_NODE_ENV]: fakeNode })).toEqual({ + executable: fakeNode, + version: "v22.23.1", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("rejects drifted tarball integrity before install can start", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-drifted-dist-")); let installStarted = false; @@ -291,6 +372,10 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( "OpenClaw real patched-dist harness", () => { it("materializes the reviewed tarball and applies NemoClaw's Dockerfile OpenClaw patches", async () => { + const nodeRuntime = resolveRealOpenClawNodeRuntime(); + console.info( + `OpenClaw real patched-dist Node runtime: ${nodeRuntime.version} (${nodeRuntime.executable})`, + ); const version = readRequiredDockerArg("OPENCLAW_VERSION"); const integrity = readRequiredDockerArg("OPENCLAW_2026_7_1_INTEGRITY"); const tarballUrl = readRequiredDockerArg("OPENCLAW_2026_7_1_TARBALL"); @@ -362,10 +447,14 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( "embedded-agent retry persistence patch preimage count", ); - const chatPatch = spawnSync(process.execPath, [PATCH_OPENCLAW_CHAT_SEND, dist], { - encoding: "utf-8", - timeout: PATCH_COMMAND_TIMEOUT_MS, - }); + const chatPatch = spawnSync( + nodeRuntime.executable, + ["--experimental-strip-types", PATCH_OPENCLAW_CHAT_SEND, dist], + { + encoding: "utf-8", + timeout: PATCH_COMMAND_TIMEOUT_MS, + }, + ); requireSpawnSuccess(chatPatch, "apply chat.send compatibility patch"); requireRuntimeIncludes( chatPatch.stdout, @@ -373,10 +462,14 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( "chat.send patch output", ); - const audit = spawnSync(process.execPath, [PATCH_OPENCLAW_CHAT_SEND, "--audit", dist], { - encoding: "utf-8", - timeout: PATCH_COMMAND_TIMEOUT_MS, - }); + const audit = spawnSync( + nodeRuntime.executable, + ["--experimental-strip-types", PATCH_OPENCLAW_CHAT_SEND, "--audit", dist], + { + encoding: "utf-8", + timeout: PATCH_COMMAND_TIMEOUT_MS, + }, + ); requireSpawnSuccess(audit, "audit chat.send compatibility patch"); requireRuntimeIncludes(audit.stdout, "chat.send runtime:", "chat.send audit"); requireRuntimeIncludes(audit.stdout, "get-reply runtime:", "get-reply audit"); @@ -397,14 +490,14 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( "embedded-agent retry persistence marker count", ); const embeddedAgentSyntax = spawnSync( - process.execPath, + nodeRuntime.executable, ["--check", retryPersistenceTargets[0] as string], { encoding: "utf-8", timeout: PATCH_COMMAND_TIMEOUT_MS }, ); requireSpawnSuccess(embeddedAgentSyntax, "validate patched embedded-agent syntax"); const issue4434Patch = spawnSync( - process.execPath, + nodeRuntime.executable, ["--experimental-strip-types", PATCH_OPENCLAW_ISSUE_4434_DIAGNOSTICS, dist], { encoding: "utf-8", @@ -419,7 +512,7 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( ); const issue4434Audit = spawnSync( - process.execPath, + nodeRuntime.executable, ["--experimental-strip-types", PATCH_OPENCLAW_ISSUE_4434_DIAGNOSTICS, "--audit", dist], { encoding: "utf-8", @@ -438,11 +531,133 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( "#4434 patch state audit", ); + const sharedStatePatch = spawnSync( + nodeRuntime.executable, + ["--experimental-strip-types", PATCH_OPENCLAW_SHARED_STATE_PERMISSIONS, dist], + { encoding: "utf-8", timeout: PATCH_COMMAND_TIMEOUT_MS }, + ); + requireSpawnSuccess(sharedStatePatch, "apply shared-state permission patch"); + requireRuntimeIncludes( + sharedStatePatch.stdout, + "SQLite state permissions patched", + "shared-state patch output", + ); + + const sharedStateAudit = spawnSync( + nodeRuntime.executable, + ["--experimental-strip-types", PATCH_OPENCLAW_SHARED_STATE_PERMISSIONS, dist], + { encoding: "utf-8", timeout: PATCH_COMMAND_TIMEOUT_MS }, + ); + requireSpawnSuccess(sharedStateAudit, "audit shared-state permission patch"); + requireRuntimeIncludes( + sharedStateAudit.stdout, + "SQLite state permissions already-patched", + "shared-state patch idempotence", + ); + + const sharedStateTargets = fs + .readdirSync(dist) + .filter((file) => /^openclaw-state-db-.+\.js$/.test(file)) + .map((file) => path.join(dist, file)) + .filter((file) => + fs.readFileSync(file, "utf-8").includes("/* nemoclaw: group-shared OpenClaw state */"), + ); + requireRuntimeEqual( + String(sharedStateTargets.length), + "1", + "shared-state patch target count", + ); + const agentStateTargets = fs + .readdirSync(dist) + .filter((file) => /^openclaw-agent-db-.+\.js$/.test(file)) + .map((file) => path.join(dist, file)) + .filter((file) => + fs + .readFileSync(file, "utf-8") + .includes("/* nemoclaw: group-shared OpenClaw agent state */"), + ); + requireRuntimeEqual( + String(agentStateTargets.length), + "1", + "per-agent state patch target count", + ); + const privateStoreTargets = fs + .readdirSync(dist) + .filter((file) => /^secret-file-.+\.js$/.test(file)) + .map((file) => path.join(dist, file)) + .filter((file) => + fs + .readFileSync(file, "utf-8") + .includes("/* nemoclaw: group-shared OpenClaw private store */"), + ); + requireRuntimeEqual( + String(privateStoreTargets.length), + "1", + "private-store patch target count", + ); + const stateMigrationTargets = fs + .readdirSync(dist) + .filter((file) => /^state-migrations-.+\.js$/.test(file)) + .map((file) => path.join(dist, file)) + .filter((file) => + fs + .readFileSync(file, "utf-8") + .includes("/* nemoclaw: ignore legacy OpenClaw update-check state */"), + ); + requireRuntimeEqual( + String(stateMigrationTargets.length), + "1", + "legacy update-check migration patch target count", + ); + const fileStoreTargets = fs + .readdirSync(dist) + .filter((file) => /^file-store-.+\.js$/.test(file)) + .map((file) => path.join(dist, file)) + .filter((file) => + fs + .readFileSync(file, "utf-8") + .includes("/* nemoclaw: group-shared OpenClaw file-store defaults */"), + ); + requireRuntimeEqual( + String(fileStoreTargets.length), + "1", + "private file-store defaults patch target count", + ); + const modelsConfigTargets = fs + .readdirSync(dist) + .filter((file) => /^models-config-.+\.js$/.test(file)) + .map((file) => path.join(dist, file)) + .filter((file) => + fs + .readFileSync(file, "utf-8") + .includes("/* nemoclaw: group-shared OpenClaw models file */"), + ); + requireRuntimeEqual( + String(modelsConfigTargets.length), + "1", + "generated models file mode patch target count", + ); + for (const target of [ + ...sharedStateTargets, + ...agentStateTargets, + ...privateStoreTargets, + ...stateMigrationTargets, + ...fileStoreTargets, + ...modelsConfigTargets, + ]) { + const syntax = spawnSync(nodeRuntime.executable, ["--check", target], { + encoding: "utf-8", + timeout: PATCH_COMMAND_TIMEOUT_MS, + }); + requireSpawnSuccess(syntax, `validate patched SQLite state syntax: ${target}`); + } + // This proof installs the reviewed shrinkwrapped runtime dependencies // with lifecycle scripts disabled. Keep it after every shape-only dist // scan so dependency materialization cannot perturb their timing. await runRealOpenClawDeviceSelfApprovalProof({ dist, + nodeExecutable: nodeRuntime.executable, patchScript: path.join(REPO_ROOT, "scripts", "patch-openclaw-device-self-approval.mts"), timeoutMs: PATCH_COMMAND_TIMEOUT_MS, tmp, diff --git a/test/openclaw-shared-state-permissions-patch.test.ts b/test/openclaw-shared-state-permissions-patch.test.ts new file mode 100644 index 00000000000..618e214d821 --- /dev/null +++ b/test/openclaw-shared-state-permissions-patch.test.ts @@ -0,0 +1,1002 @@ +// 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 { pathToFileURL } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + AGENT_MARKER, + FILE_STORE_MARKER, + MARKER, + MIGRATION_MARKER, + MODELS_MARKER, + patchOpenClawAgentDbText, + patchOpenClawFileStoreText, + patchOpenClawModelsConfigText, + patchOpenClawSecretFileText, + patchOpenClawSharedStatePermissions, + patchOpenClawStateDbText, + patchOpenClawStateMigrationText, + SECRET_MARKER, +} from "../scripts/patch-openclaw-shared-state-permissions.mts"; + +const PATCH_SCRIPT = path.join( + import.meta.dirname, + "..", + "scripts", + "patch-openclaw-shared-state-permissions.mts", +); + +const UPSTREAM_STATE_DB_SOURCE = [ + 'import { chmodSync, existsSync, mkdirSync, statSync as realStatSync, unlinkSync } from "node:fs";', + 'import path from "node:path";', + "", + "const OPENCLAW_STATE_DIR_MODE = 448;", + "const OPENCLAW_STATE_FILE_MODE = 384;", + "const chmodWarnedTargets = new Set();", + "const chmodCalls = [];", + "const chmodWarnings = [];", + "let disappearOnStat = '';", + "const stateDbLog = { warn(message) { chmodWarnings.push(message); } };", + "function statSync(target) {", + "\tif (target === disappearOnStat) {", + "\t\tdisappearOnStat = '';", + "\t\tunlinkSync(target);", + "\t}", + "\treturn realStatSync(target);", + "}", + "function applyPrivateModeSync(target, mode) {", + "\tchmodCalls.push({ target, mode });", + "\ttry {", + "\t\tchmodSync(target, mode);", + "\t\treturn { applied: true };", + "\t} catch (error) {", + "\t\treturn { applied: false, error };", + "\t}", + "}", + "function bestEffortChmodSync(target, mode) {", + "\tconst result = applyPrivateModeSync(target, mode);", + "\tif (result.applied || chmodWarnedTargets.has(target)) return;", + "\tchmodWarnedTargets.add(target);", + "\tstateDbLog.warn(`skipped permission hardening for ${target}: ${String(result.error)}`);", + "}", + "function resolveOpenClawStateSqliteDir(env) { return env.OPENCLAW_STATE_DIR; }", + "function resolveOpenClawStateSqlitePath(env) { return path.join(env.OPENCLAW_STATE_DIR, 'openclaw.sqlite'); }", + "function resolveSqliteDatabaseFilePaths(pathname) { return [pathname, `${pathname}-wal`, `${pathname}-shm`, `${pathname}-journal`]; }", + "function ensureOpenClawStatePermissions(pathname, env) {", + "\tconst dir = path.dirname(pathname);", + "\tconst defaultDir = resolveOpenClawStateSqliteDir(env);", + "\tconst isDefaultStateDatabase = path.resolve(pathname) === path.resolve(resolveOpenClawStateSqlitePath(env));", + "\tif (isDefaultStateDatabase && dir !== defaultDir) throw new Error(`OpenClaw state database path resolved outside its state dir: ${pathname}`);", + "\tconst dirExisted = existsSync(dir);", + "\tmkdirSync(dir, {", + "\t\trecursive: true,", + "\t\tmode: OPENCLAW_STATE_DIR_MODE", + "\t});", + "\tif (isDefaultStateDatabase || !dirExisted) bestEffortChmodSync(dir, OPENCLAW_STATE_DIR_MODE);", + "\tfor (const candidate of resolveSqliteDatabaseFilePaths(pathname)) if (existsSync(candidate)) bestEffortChmodSync(candidate, OPENCLAW_STATE_FILE_MODE);", + "}", + "function resetChmodCalls() { chmodCalls.length = 0; chmodWarnings.length = 0; }", + "function setDisappearOnStat(target) { disappearOnStat = target; }", + "export { chmodCalls, chmodWarnings, ensureOpenClawStatePermissions, resetChmodCalls, setDisappearOnStat };", + "", +].join("\n"); + +const UPSTREAM_AGENT_DB_SOURCE = [ + 'import { chmodSync, existsSync, mkdirSync, statSync } from "node:fs";', + 'import path from "node:path";', + "", + "const OPENCLAW_AGENT_DB_DIR_MODE = 448;", + "const OPENCLAW_AGENT_DB_FILE_MODE = 384;", + "function resolveOpenClawAgentSqlitePath(options) { return path.join(options.env.OPENCLAW_AGENT_DIR, `${options.agentId}.sqlite`); }", + "function resolveSqliteDatabaseFilePaths(pathname) { return [pathname, `${pathname}-wal`, `${pathname}-shm`, `${pathname}-journal`]; }", + "function ensureOpenClawAgentDatabasePermissions(pathname, options) {", + "\tconst dir = path.dirname(pathname);", + "\tconst defaultPath = resolveOpenClawAgentSqlitePath({", + "\t\tagentId: options.agentId,", + "\t\tenv: options.env", + "\t});", + "\tconst isDefaultAgentDatabase = path.resolve(pathname) === path.resolve(defaultPath);", + "\tconst dirExisted = existsSync(dir);", + "\tmkdirSync(dir, {", + "\t\trecursive: true,", + "\t\tmode: OPENCLAW_AGENT_DB_DIR_MODE", + "\t});", + "\tif (isDefaultAgentDatabase || !dirExisted) chmodSync(dir, OPENCLAW_AGENT_DB_DIR_MODE);", + "\tfor (const candidate of resolveSqliteDatabaseFilePaths(pathname)) if (existsSync(candidate)) chmodSync(candidate, OPENCLAW_AGENT_DB_FILE_MODE);", + "}", + "export { ensureOpenClawAgentDatabasePermissions };", + "", +].join("\n"); + +const UPSTREAM_SECRET_FILE_SOURCE = [ + 'import fs$1 from "node:fs/promises";', + "", + "const PRIVATE_SECRET_DIR_MODE = 448;", + "const PRIVATE_SECRET_FILE_MODE = 384;", + "async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {", + '\tif (process.platform === "win32") return;', + "\tawait fs$1.chmod(resolvedPath, expectedMode);", + "\tconst actualMode = (await fs$1.stat(resolvedPath)).mode & 511;", + "\tif (actualMode !== expectedMode) throw new Error(`Private secret ${kind} ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);", + "}", + "async function writeSecretFileAtomic(params) {", + "\tconst mode = params.mode ?? 384;", + "\tconst dirMode = params.dirMode ?? 448;", + "\treturn { dirMode, mode };", + "}", + "export { enforcePrivatePathMode, writeSecretFileAtomic };", + "", +].join("\n"); + +const UPSTREAM_STATE_MIGRATION_SOURCE = [ + 'import { existsSync } from "node:fs";', + "", + "const fileExists = existsSync;", + "function migrateLegacyUpdateCheckState(params) {", + "\tconst changes = [];", + "\tconst warnings = [];", + "\tif (!fileExists(params.detected.sourcePath)) return {", + "\t\tchanges,", + "\t\twarnings", + "\t};", + '\twarnings.push("upstream update-check migration ran");', + "\treturn { changes, warnings };", + "}", + "export { migrateLegacyUpdateCheckState };", + "", +].join("\n"); + +const UPSTREAM_FILE_STORE_SOURCE = [ + 'import path from "node:path";', + "", + "function fileStore(options) {", + "\tconst rootDir = path.resolve(options.rootDir);", + "\tconst privateMode = options.private ?? false;", + "\tconst dirMode = options.dirMode ?? 448;", + "\tconst mode = options.mode ?? 384;", + "\treturn { dirMode, mode, privateMode, rootDir };", + "}", + "function fileStoreSync(options) {", + "\tconst rootDir = path.resolve(options.rootDir);", + "\tconst privateMode = options.private ?? false;", + "\tconst dirMode = options.dirMode ?? 448;", + "\tconst mode = options.mode ?? 384;", + "\treturn { dirMode, mode, privateMode, rootDir };", + "}", + "export { fileStore, fileStoreSync };", + "", +].join("\n"); + +const UPSTREAM_MODELS_CONFIG_SOURCE = [ + 'import realFs from "node:fs/promises";', + "", + "const chmodCalls = [];", + "const fs = {", + "\tstat: realFs.stat,", + "\tasync chmod(pathname, requestedMode) {", + "\t\tchmodCalls.push({ pathname, mode: requestedMode });", + "\t\tawait realFs.chmod(pathname, requestedMode);", + "\t}", + "};", + "async function ensureModelsFileModeForModelsJson(pathname) {", + "\tawait fs.chmod(pathname, 384).catch(() => {});", + "}", + "async function writeModelsFileAtomicForModelsJson(targetPath, contents) {", + "\treturn { contents, targetPath };", + "}", + "function resetChmodCalls() { chmodCalls.length = 0; }", + "export { chmodCalls, ensureModelsFileModeForModelsJson, resetChmodCalls };", + "", +].join("\n"); + +interface StateFixtureRuntime { + chmodCalls: Array<{ target: string; mode: number }>; + chmodWarnings: string[]; + ensureOpenClawStatePermissions( + pathname: string, + env: { + NEMOCLAW_OPENCLAW_SHARED_STATE?: string; + OPENCLAW_STATE_DIR: string; + OPENSHELL_SANDBOX?: string; + }, + ): void; + resetChmodCalls(): void; + setDisappearOnStat(target: string): void; +} + +interface AgentFixtureRuntime { + ensureOpenClawAgentDatabasePermissions( + pathname: string, + options: { + agentId: string; + env: { + NEMOCLAW_OPENCLAW_SHARED_STATE?: string; + OPENCLAW_AGENT_DIR: string; + OPENSHELL_SANDBOX?: string; + }; + }, + ): void; +} + +interface SecretFixtureRuntime { + enforcePrivatePathMode(pathname: string, expectedMode: number, kind: string): Promise; + writeSecretFileAtomic(params: { dirMode?: number; mode?: number }): Promise<{ + dirMode: number; + mode: number; + }>; +} + +interface MigrationFixtureRuntime { + migrateLegacyUpdateCheckState(params: { detected: { sourcePath: string } }): { + changes: string[]; + warnings: string[]; + }; +} + +interface FileStoreFixtureRuntime { + fileStore(options: FileStoreFixtureOptions): FileStoreFixtureResult; + fileStoreSync(options: FileStoreFixtureOptions): FileStoreFixtureResult; +} + +interface FileStoreFixtureOptions { + rootDir: string; + private?: boolean; + dirMode?: number; + mode?: number; +} + +interface FileStoreFixtureResult { + dirMode: number; + mode: number; + privateMode: boolean; + rootDir: string; +} + +interface ModelsFixtureRuntime { + chmodCalls: Array<{ pathname: string; mode: number }>; + ensureModelsFileModeForModelsJson(pathname: string): Promise; + resetChmodCalls(): void; +} + +function mode(file: string): number { + return fs.statSync(file).mode & 0o7777; +} + +function makeFixture( + stateFileCount = 1, + agentFileCount = 1, + secretFileCount = 1, + migrationFileCount = 1, + fileStoreFileCount = 1, + modelsFileCount = 1, +) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-state-mode-")); + const dist = path.join(root, "dist"); + fs.mkdirSync(dist); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ type: "module" })); + const stateFiles: string[] = []; + for (let index = 0; index < stateFileCount; index += 1) { + const file = path.join(dist, `openclaw-state-db-${index}.js`); + fs.writeFileSync(file, UPSTREAM_STATE_DB_SOURCE); + stateFiles.push(file); + } + const agentFiles: string[] = []; + for (let index = 0; index < agentFileCount; index += 1) { + const file = path.join(dist, `openclaw-agent-db-${index}.js`); + fs.writeFileSync(file, UPSTREAM_AGENT_DB_SOURCE); + agentFiles.push(file); + } + const secretFiles: string[] = []; + for (let index = 0; index < secretFileCount; index += 1) { + const file = path.join(dist, `secret-file-${index}.js`); + fs.writeFileSync(file, UPSTREAM_SECRET_FILE_SOURCE); + secretFiles.push(file); + } + const migrationFiles: string[] = []; + for (let index = 0; index < migrationFileCount; index += 1) { + const file = path.join(dist, `state-migrations-${index}.js`); + fs.writeFileSync(file, UPSTREAM_STATE_MIGRATION_SOURCE); + migrationFiles.push(file); + } + const fileStoreFiles: string[] = []; + for (let index = 0; index < fileStoreFileCount; index += 1) { + const file = path.join(dist, `file-store-${index}.js`); + fs.writeFileSync(file, UPSTREAM_FILE_STORE_SOURCE); + fileStoreFiles.push(file); + } + const modelsFiles: string[] = []; + for (let index = 0; index < modelsFileCount; index += 1) { + const file = path.join(dist, `models-config-${index}.js`); + fs.writeFileSync(file, UPSTREAM_MODELS_CONFIG_SOURCE); + modelsFiles.push(file); + } + return { + agentFiles, + dist, + fileStoreFiles, + migrationFiles, + modelsFiles, + root, + secretFiles, + stateFiles, + }; +} + +function runPatch(dist: string) { + return spawnSync(process.execPath, ["--experimental-strip-types", PATCH_SCRIPT, dist], { + encoding: "utf8", + timeout: 10_000, + }); +} + +async function importStateFixture(file: string): Promise { + return (await import( + `${pathToFileURL(file).href}?test=${Date.now()}-${Math.random()}` + )) as StateFixtureRuntime; +} + +async function importAgentFixture(file: string): Promise { + return (await import( + `${pathToFileURL(file).href}?test=${Date.now()}-${Math.random()}` + )) as AgentFixtureRuntime; +} + +async function importSecretFixture(file: string): Promise { + return (await import( + `${pathToFileURL(file).href}?test=${Date.now()}-${Math.random()}` + )) as SecretFixtureRuntime; +} + +async function importMigrationFixture(file: string): Promise { + return (await import( + `${pathToFileURL(file).href}?test=${Date.now()}-${Math.random()}` + )) as MigrationFixtureRuntime; +} + +async function importFileStoreFixture(file: string): Promise { + return (await import( + `${pathToFileURL(file).href}?test=${Date.now()}-${Math.random()}` + )) as FileStoreFixtureRuntime; +} + +async function importModelsFixture(file: string): Promise { + return (await import( + `${pathToFileURL(file).href}?test=${Date.now()}-${Math.random()}` + )) as ModelsFixtureRuntime; +} + +describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { + it("applies every exact target once through the CLI and remains idempotent", () => { + const fixture = makeFixture(); + try { + const first = runPatch(fixture.dist); + expect(first.status, `${first.stdout}${first.stderr}`).toBe(0); + expect(first.stdout).toContain("SQLite state permissions patched"); + const patchedState = fs.readFileSync(fixture.stateFiles[0], "utf8"); + const patchedAgent = fs.readFileSync(fixture.agentFiles[0], "utf8"); + const patchedSecret = fs.readFileSync(fixture.secretFiles[0], "utf8"); + const patchedMigration = fs.readFileSync(fixture.migrationFiles[0], "utf8"); + const patchedFileStore = fs.readFileSync(fixture.fileStoreFiles[0], "utf8"); + const patchedModels = fs.readFileSync(fixture.modelsFiles[0], "utf8"); + expect(patchedState.split(MARKER)).toHaveLength(2); + expect(patchedAgent.split(AGENT_MARKER)).toHaveLength(2); + expect(patchedSecret.split(SECRET_MARKER)).toHaveLength(2); + expect(patchedMigration.split(MIGRATION_MARKER)).toHaveLength(2); + expect(patchedFileStore.split(FILE_STORE_MARKER)).toHaveLength(2); + expect(patchedModels.split(MODELS_MARKER)).toHaveLength(2); + expect(patchedState).toContain("NEMOCLAW_SHARED_STATE_DIR_MODE = 0o2770"); + expect(patchedAgent).toContain("NEMOCLAW_SHARED_AGENT_DB_DIR_MODE = 0o2770"); + expect(patchedSecret).toContain("NEMOCLAW_SHARED_SECRET_DIR_MODE = 0o2770"); + + const second = runPatch(fixture.dist); + expect(second.status, `${second.stdout}${second.stderr}`).toBe(0); + expect(second.stdout).toContain("SQLite state permissions already-patched"); + expect(fs.readFileSync(fixture.stateFiles[0], "utf8")).toBe(patchedState); + expect(fs.readFileSync(fixture.agentFiles[0], "utf8")).toBe(patchedAgent); + expect(fs.readFileSync(fixture.secretFiles[0], "utf8")).toBe(patchedSecret); + expect(fs.readFileSync(fixture.migrationFiles[0], "utf8")).toBe(patchedMigration); + expect(fs.readFileSync(fixture.fileStoreFiles[0], "utf8")).toBe(patchedFileStore); + expect(fs.readFileSync(fixture.modelsFiles[0], "utf8")).toBe(patchedModels); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it.each([ + "1", + "sandbox-name", + ])("uses group-shared state modes for the OpenShell marker %s", async (openShellMarker) => { + const fixture = makeFixture(); + try { + patchOpenClawSharedStatePermissions(fixture.dist); + const runtime = await importStateFixture(fixture.stateFiles[0]); + const stateDir = path.join(fixture.root, "runtime-state"); + const database = path.join(stateDir, "openclaw.sqlite"); + const wal = `${database}-wal`; + const env = { OPENCLAW_STATE_DIR: stateDir, OPENSHELL_SANDBOX: openShellMarker }; + + runtime.ensureOpenClawStatePermissions(database, env); + fs.writeFileSync(database, ""); + fs.writeFileSync(wal, ""); + runtime.ensureOpenClawStatePermissions(database, env); + + expect(mode(stateDir)).toBe(0o2770); + expect(mode(database)).toBe(0o660); + expect(mode(wal)).toBe(0o660); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("uses group-shared modes for direct NemoClaw containers", async () => { + const fixture = makeFixture(); + try { + patchOpenClawSharedStatePermissions(fixture.dist); + const runtime = await importStateFixture(fixture.stateFiles[0]); + const stateDir = path.join(fixture.root, "direct-state"); + const database = path.join(stateDir, "openclaw.sqlite"); + const env = { + NEMOCLAW_OPENCLAW_SHARED_STATE: "1", + OPENCLAW_STATE_DIR: stateDir, + OPENSHELL_SANDBOX: "", + }; + + runtime.ensureOpenClawStatePermissions(database, env); + fs.writeFileSync(database, ""); + runtime.ensureOpenClawStatePermissions(database, env); + + expect(mode(stateDir)).toBe(0o2770); + expect(mode(database)).toBe(0o660); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("uses the process marker when a worker passes a narrowed environment", async () => { + const fixture = makeFixture(); + const previousMarker = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + try { + patchOpenClawSharedStatePermissions(fixture.dist); + const runtime = await importStateFixture(fixture.stateFiles[0]); + const stateDir = path.join(fixture.root, "worker-state"); + const database = path.join(stateDir, "openclaw.sqlite"); + process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = "1"; + + runtime.ensureOpenClawStatePermissions(database, { OPENCLAW_STATE_DIR: stateDir }); + fs.writeFileSync(database, ""); + runtime.ensureOpenClawStatePermissions(database, { OPENCLAW_STATE_DIR: stateDir }); + + expect(mode(stateDir)).toBe(0o2770); + expect(mode(database)).toBe(0o660); + } finally { + if (previousMarker === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousMarker; + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("uses group-shared modes for the per-agent database", async () => { + const fixture = makeFixture(); + try { + patchOpenClawSharedStatePermissions(fixture.dist); + const runtime = await importAgentFixture(fixture.agentFiles[0]); + const agentDir = path.join(fixture.root, "agent-state"); + const database = path.join(agentDir, "main.sqlite"); + const options = { + agentId: "main", + env: { + NEMOCLAW_OPENCLAW_SHARED_STATE: "1", + OPENCLAW_AGENT_DIR: agentDir, + OPENSHELL_SANDBOX: "", + }, + }; + + runtime.ensureOpenClawAgentDatabasePermissions(database, options); + fs.writeFileSync(database, ""); + runtime.ensureOpenClawAgentDatabasePermissions(database, options); + + expect(mode(agentDir)).toBe(0o2770); + expect(mode(database)).toBe(0o660); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("keeps private-store paths group-accessible without rechmodding inherited directories", async () => { + const fixture = makeFixture(); + const previousMarker = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + try { + patchOpenClawSharedStatePermissions(fixture.dist); + process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = "1"; + const runtime = await importSecretFixture(fixture.secretFiles[0]); + const privateDir = path.join(fixture.root, "private-store"); + fs.mkdirSync(privateDir, { mode: 0o700 }); + fs.chmodSync(privateDir, 0o2770); + + const defaults = await runtime.writeSecretFileAtomic({}); + expect(defaults).toEqual({ dirMode: 0o2770, mode: 0o660 }); + await runtime.enforcePrivatePathMode(privateDir, defaults.dirMode, "directory"); + expect(mode(privateDir)).toBe(0o2770); + } finally { + if (previousMarker === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousMarker; + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform !== "linux" || (process.getuid?.() ?? -1) !== 0)( + "preserves the shared group on files created by a different private-store user", + async () => { + const fixture = makeFixture(); + const previousMarker = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + try { + patchOpenClawSharedStatePermissions(fixture.dist); + process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = "1"; + const runtime = await importFileStoreFixture(fixture.fileStoreFiles[0]); + const sharedDir = path.join(fixture.root, "cross-user-private-store"); + const sharedFile = path.join(sharedDir, "identity.json"); + const defaults = runtime.fileStore({ rootDir: sharedDir, private: true }); + const sharedGid = 65_534; + fs.chmodSync(fixture.root, 0o755); + fs.mkdirSync(sharedDir, { mode: 0o700 }); + fs.chownSync(sharedDir, 0, sharedGid); + fs.chmodSync(sharedDir, defaults.dirMode); + + const writer = spawnSync( + process.execPath, + [ + "-e", + `process.umask(0); require("node:fs").writeFileSync(${JSON.stringify(sharedFile)}, "shared", { mode: ${defaults.mode} });`, + ], + { encoding: "utf8", gid: sharedGid, uid: 65_532 }, + ); + expect(writer.status, writer.stderr).toBe(0); + expect(fs.statSync(sharedFile).gid).toBe(sharedGid); + expect(mode(sharedFile)).toBe(0o660); + + const reader = spawnSync( + process.execPath, + [ + "-e", + `process.stdout.write(require("node:fs").readFileSync(${JSON.stringify(sharedFile)}, "utf8"))`, + ], + { encoding: "utf8", gid: sharedGid, uid: 65_533 }, + ); + expect(reader.status, reader.stderr).toBe(0); + expect(reader.stdout).toBe("shared"); + } finally { + if (previousMarker === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousMarker; + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }, + ); + + it("selects group-shared defaults for normal async and sync private stores only", async () => { + const fixture = makeFixture(); + const previousMarker = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + try { + patchOpenClawSharedStatePermissions(fixture.dist); + const runtime = await importFileStoreFixture(fixture.fileStoreFiles[0]); + process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = "1"; + const rootDir = path.join(fixture.root, "normal-private-store"); + + for (const result of [ + runtime.fileStore({ rootDir, private: true }), + runtime.fileStoreSync({ rootDir, private: true }), + ]) { + expect(result).toMatchObject({ dirMode: 0o2770, mode: 0o660, privateMode: true }); + } + expect(runtime.fileStore({ rootDir })).toMatchObject({ + dirMode: 0o700, + mode: 0o600, + privateMode: false, + }); + expect( + runtime.fileStore({ rootDir, private: true, dirMode: 0o750, mode: 0o640 }), + ).toMatchObject({ dirMode: 0o750, mode: 0o640, privateMode: true }); + } finally { + if (previousMarker === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousMarker; + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("retains owner-only private-store defaults outside NemoClaw", async () => { + const fixture = makeFixture(); + const previousShared = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + const previousOpenShell = process.env.OPENSHELL_SANDBOX; + try { + patchOpenClawSharedStatePermissions(fixture.dist); + delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + process.env.OPENSHELL_SANDBOX = "sandbox_name"; + const runtime = await importFileStoreFixture(fixture.fileStoreFiles[0]); + const rootDir = path.join(fixture.root, "upstream-private-store"); + + expect(runtime.fileStore({ rootDir, private: true })).toMatchObject({ + dirMode: 0o700, + mode: 0o600, + privateMode: true, + }); + expect(runtime.fileStoreSync({ rootDir, private: true })).toMatchObject({ + dirMode: 0o700, + mode: 0o600, + privateMode: true, + }); + } finally { + if (previousShared === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousShared; + if (previousOpenShell === undefined) delete process.env.OPENSHELL_SANDBOX; + else process.env.OPENSHELL_SANDBOX = previousOpenShell; + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it.each([ + "1", + "sandbox-name", + ])("keeps generated models files group-readable under the OpenShell marker %s", async (openShellMarker) => { + const fixture = makeFixture(); + const previousShared = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + const previousOpenShell = process.env.OPENSHELL_SANDBOX; + try { + patchOpenClawSharedStatePermissions(fixture.dist); + delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + process.env.OPENSHELL_SANDBOX = openShellMarker; + const runtime = await importModelsFixture(fixture.modelsFiles[0]); + const modelsFile = path.join(fixture.root, "models.json"); + fs.writeFileSync(modelsFile, "{}", { mode: 0o600 }); + + await runtime.ensureModelsFileModeForModelsJson(modelsFile); + expect(mode(modelsFile)).toBe(0o660); + expect(runtime.chmodCalls).toEqual([{ pathname: modelsFile, mode: 0o660 }]); + + runtime.resetChmodCalls(); + await runtime.ensureModelsFileModeForModelsJson(modelsFile); + expect(runtime.chmodCalls).toEqual([]); + } finally { + if (previousShared === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousShared; + if (previousOpenShell === undefined) delete process.env.OPENSHELL_SANDBOX; + else process.env.OPENSHELL_SANDBOX = previousOpenShell; + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("retains the upstream generated-models mode outside NemoClaw", async () => { + const fixture = makeFixture(); + const previousShared = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + const previousOpenShell = process.env.OPENSHELL_SANDBOX; + try { + patchOpenClawSharedStatePermissions(fixture.dist); + delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + process.env.OPENSHELL_SANDBOX = "sandbox_name"; + const runtime = await importModelsFixture(fixture.modelsFiles[0]); + const modelsFile = path.join(fixture.root, "models.json"); + fs.writeFileSync(modelsFile, "{}", { mode: 0o660 }); + + await runtime.ensureModelsFileModeForModelsJson(modelsFile); + expect(mode(modelsFile)).toBe(0o600); + expect(runtime.chmodCalls).toEqual([{ pathname: modelsFile, mode: 0o600 }]); + } finally { + if (previousShared === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousShared; + if (previousOpenShell === undefined) delete process.env.OPENSHELL_SANDBOX; + else process.env.OPENSHELL_SANDBOX = previousOpenShell; + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it.each([ + ["NEMOCLAW_OPENCLAW_SHARED_STATE", "1"], + ["OPENSHELL_SANDBOX", "sandbox-name"], + ] as const)("ignores legacy update-check migration state under %s", async (markerName, markerValue) => { + const fixture = makeFixture(); + const previousShared = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + const previousOpenShell = process.env.OPENSHELL_SANDBOX; + try { + patchOpenClawSharedStatePermissions(fixture.dist); + delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + delete process.env.OPENSHELL_SANDBOX; + process.env[markerName] = markerValue; + const cache = path.join(fixture.root, "update-check.json"); + fs.writeFileSync(cache, "not even valid JSON"); + const runtime = await importMigrationFixture(fixture.migrationFiles[0]); + + expect(runtime.migrateLegacyUpdateCheckState({ detected: { sourcePath: cache } })).toEqual({ + changes: [], + warnings: [], + }); + } finally { + if (previousShared === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousShared; + if (previousOpenShell === undefined) delete process.env.OPENSHELL_SANDBOX; + else process.env.OPENSHELL_SANDBOX = previousOpenShell; + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("retains the upstream update-check migration behavior outside NemoClaw", async () => { + const fixture = makeFixture(); + const previousShared = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + const previousOpenShell = process.env.OPENSHELL_SANDBOX; + try { + patchOpenClawSharedStatePermissions(fixture.dist); + delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + process.env.OPENSHELL_SANDBOX = "sandbox_name"; + const cache = path.join(fixture.root, "update-check.json"); + fs.writeFileSync(cache, "{}"); + const runtime = await importMigrationFixture(fixture.migrationFiles[0]); + + expect(runtime.migrateLegacyUpdateCheckState({ detected: { sourcePath: cache } })).toEqual({ + changes: [], + warnings: ["upstream update-check migration ran"], + }); + } finally { + if (previousShared === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; + else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousShared; + if (previousOpenShell === undefined) delete process.env.OPENSHELL_SANDBOX; + else process.env.OPENSHELL_SANDBOX = previousOpenShell; + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it.each([ + "", + "0", + "TRUE", + "sandbox_name", + "-sandbox", + "sandbox-", + ])("retains upstream private modes for an invalid OpenShell marker %j", async (openShellMarker) => { + const fixture = makeFixture(); + try { + patchOpenClawSharedStatePermissions(fixture.dist); + const runtime = await importStateFixture(fixture.stateFiles[0]); + const stateDir = path.join(fixture.root, "private-state"); + const database = path.join(stateDir, "openclaw.sqlite"); + const env = { + NEMOCLAW_OPENCLAW_SHARED_STATE: "", + OPENCLAW_STATE_DIR: stateDir, + OPENSHELL_SANDBOX: openShellMarker, + }; + runtime.ensureOpenClawStatePermissions(database, env); + fs.writeFileSync(database, ""); + runtime.ensureOpenClawStatePermissions(database, env); + expect(mode(stateDir)).toBe(0o700); + expect(mode(database)).toBe(0o600); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("skips chmod for already-matching group-shared state", async () => { + const fixture = makeFixture(); + try { + patchOpenClawSharedStatePermissions(fixture.dist); + const runtime = await importStateFixture(fixture.stateFiles[0]); + const stateDir = path.join(fixture.root, "preowned-state"); + const database = path.join(stateDir, "openclaw.sqlite"); + const wal = `${database}-wal`; + fs.mkdirSync(stateDir, { mode: 0o700 }); + fs.writeFileSync(database, ""); + fs.writeFileSync(wal, ""); + fs.chmodSync(stateDir, 0o2770); + fs.chmodSync(database, 0o660); + fs.chmodSync(wal, 0o660); + runtime.resetChmodCalls(); + + runtime.ensureOpenClawStatePermissions(database, { + NEMOCLAW_OPENCLAW_SHARED_STATE: "1", + OPENCLAW_STATE_DIR: stateDir, + }); + + expect(runtime.chmodCalls).toEqual([]); + expect(mode(stateDir)).toBe(0o2770); + expect(mode(database)).toBe(0o660); + expect(mode(wal)).toBe(0o660); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("preserves best-effort behavior when a SQLite sidecar disappears before the mode probe", async () => { + const fixture = makeFixture(); + try { + patchOpenClawSharedStatePermissions(fixture.dist); + const runtime = await importStateFixture(fixture.stateFiles[0]); + const stateDir = path.join(fixture.root, "racing-state"); + const database = path.join(stateDir, "openclaw.sqlite"); + const wal = `${database}-wal`; + fs.mkdirSync(stateDir, { mode: 0o700 }); + fs.writeFileSync(database, ""); + fs.writeFileSync(wal, ""); + fs.chmodSync(stateDir, 0o2770); + fs.chmodSync(database, 0o660); + fs.chmodSync(wal, 0o660); + runtime.resetChmodCalls(); + runtime.setDisappearOnStat(wal); + + expect(() => + runtime.ensureOpenClawStatePermissions(database, { + NEMOCLAW_OPENCLAW_SHARED_STATE: "1", + OPENCLAW_STATE_DIR: stateDir, + }), + ).not.toThrow(); + + expect(fs.existsSync(wal)).toBe(false); + expect(runtime.chmodCalls).toEqual([{ target: wal, mode: 0o660 }]); + expect(runtime.chmodWarnings).toHaveLength(1); + expect(runtime.chmodWarnings[0]).toContain(wal); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it("fails closed on missing, ambiguous, and partial source shapes", () => { + const missing = makeFixture(0, 1, 1); + try { + expect(() => patchOpenClawSharedStatePermissions(missing.dist)).toThrow( + "Expected exactly one OpenClaw shared-state database target", + ); + } finally { + fs.rmSync(missing.root, { recursive: true, force: true }); + } + + const ambiguous = makeFixture(2, 1, 1); + try { + expect(() => patchOpenClawSharedStatePermissions(ambiguous.dist)).toThrow("found 2"); + for (const file of [ + ...ambiguous.stateFiles, + ...ambiguous.agentFiles, + ...ambiguous.secretFiles, + ...ambiguous.migrationFiles, + ...ambiguous.fileStoreFiles, + ...ambiguous.modelsFiles, + ]) { + expect(fs.readFileSync(file, "utf8")).not.toContain("nemoclaw: group-shared"); + } + } finally { + fs.rmSync(ambiguous.root, { recursive: true, force: true }); + } + + const missingMigration = makeFixture(1, 1, 1, 0); + try { + expect(() => patchOpenClawSharedStatePermissions(missingMigration.dist)).toThrow( + "Expected exactly one OpenClaw state-migration target", + ); + for (const file of [ + ...missingMigration.stateFiles, + ...missingMigration.agentFiles, + ...missingMigration.secretFiles, + ...missingMigration.fileStoreFiles, + ...missingMigration.modelsFiles, + ]) { + expect(fs.readFileSync(file, "utf8")).not.toContain("nemoclaw: group-shared"); + } + } finally { + fs.rmSync(missingMigration.root, { recursive: true, force: true }); + } + + const ambiguousMigration = makeFixture(1, 1, 1, 2); + try { + expect(() => patchOpenClawSharedStatePermissions(ambiguousMigration.dist)).toThrow( + "Expected exactly one OpenClaw state-migration target", + ); + for (const file of [ + ...ambiguousMigration.stateFiles, + ...ambiguousMigration.agentFiles, + ...ambiguousMigration.secretFiles, + ...ambiguousMigration.migrationFiles, + ...ambiguousMigration.fileStoreFiles, + ...ambiguousMigration.modelsFiles, + ]) { + expect(fs.readFileSync(file, "utf8")).not.toContain("nemoclaw:"); + } + } finally { + fs.rmSync(ambiguousMigration.root, { recursive: true, force: true }); + } + + const missingFileStore = makeFixture(1, 1, 1, 1, 0); + try { + expect(() => patchOpenClawSharedStatePermissions(missingFileStore.dist)).toThrow( + "Expected exactly one OpenClaw file-store target", + ); + } finally { + fs.rmSync(missingFileStore.root, { recursive: true, force: true }); + } + + const ambiguousFileStore = makeFixture(1, 1, 1, 1, 2); + try { + expect(() => patchOpenClawSharedStatePermissions(ambiguousFileStore.dist)).toThrow( + "Expected exactly one OpenClaw file-store target", + ); + for (const file of [ + ...ambiguousFileStore.stateFiles, + ...ambiguousFileStore.agentFiles, + ...ambiguousFileStore.secretFiles, + ...ambiguousFileStore.migrationFiles, + ...ambiguousFileStore.fileStoreFiles, + ...ambiguousFileStore.modelsFiles, + ]) { + expect(fs.readFileSync(file, "utf8")).not.toContain("nemoclaw:"); + } + } finally { + fs.rmSync(ambiguousFileStore.root, { recursive: true, force: true }); + } + + const missingModels = makeFixture(1, 1, 1, 1, 1, 0); + try { + expect(() => patchOpenClawSharedStatePermissions(missingModels.dist)).toThrow( + "Expected exactly one OpenClaw models-config target", + ); + } finally { + fs.rmSync(missingModels.root, { recursive: true, force: true }); + } + + const ambiguousModels = makeFixture(1, 1, 1, 1, 1, 2); + try { + expect(() => patchOpenClawSharedStatePermissions(ambiguousModels.dist)).toThrow( + "Expected exactly one OpenClaw models-config target", + ); + for (const file of [ + ...ambiguousModels.stateFiles, + ...ambiguousModels.agentFiles, + ...ambiguousModels.secretFiles, + ...ambiguousModels.migrationFiles, + ...ambiguousModels.fileStoreFiles, + ...ambiguousModels.modelsFiles, + ]) { + expect(fs.readFileSync(file, "utf8")).not.toContain("nemoclaw:"); + } + } finally { + fs.rmSync(ambiguousModels.root, { recursive: true, force: true }); + } + + expect(() => + patchOpenClawStateDbText(`${UPSTREAM_STATE_DB_SOURCE}\n${MARKER}\n`, "partial.js"), + ).toThrow("expected exactly one patched pattern"); + expect(() => + patchOpenClawAgentDbText(`${UPSTREAM_AGENT_DB_SOURCE}\n${AGENT_MARKER}\n`, "partial.js"), + ).toThrow("expected exactly one patched pattern"); + expect(() => + patchOpenClawSecretFileText( + `${UPSTREAM_SECRET_FILE_SOURCE}\n${SECRET_MARKER}\n`, + "partial.js", + ), + ).toThrow("expected exactly one patched pattern"); + expect(() => + patchOpenClawStateMigrationText( + `${UPSTREAM_STATE_MIGRATION_SOURCE}\n${MIGRATION_MARKER}\n`, + "partial.js", + ), + ).toThrow("expected exactly one patched pattern"); + expect(() => + patchOpenClawFileStoreText( + `${UPSTREAM_FILE_STORE_SOURCE}\n${FILE_STORE_MARKER}\n`, + "partial.js", + ), + ).toThrow("expected exactly one patched pattern"); + expect(() => + patchOpenClawModelsConfigText( + `${UPSTREAM_MODELS_CONFIG_SOURCE}\n${MODELS_MARKER}\n`, + "partial.js", + ), + ).toThrow("expected exactly one patched pattern"); + expect(() => + patchOpenClawStateDbText( + UPSTREAM_STATE_DB_SOURCE.replace( + "function bestEffortChmodSync(target, mode) {", + "function bestEffortChmodSync(target, requestedMode) {", + ), + "drifted.js", + ), + ).toThrow("expected exactly one chmod helper, found 0"); + }); +}); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 6b11c5e1363..dbc9672e832 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -112,6 +112,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "patch-openclaw-mcp-npx.mts")); writeFixture(path.join("scripts", "patch-openclaw-issue-4434-diagnostics.mts")); writeFixture(path.join("scripts", "patch-openclaw-device-self-approval.mts")); + writeFixture(path.join("scripts", "patch-openclaw-shared-state-permissions.mts")); writeFixture(path.join("scripts", "verify-wechat-runtime-lock.mts")); writeFixture(path.join("scripts", "lib", "reviewed-npm-archive.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "openclaw-npm-remediation.mts"), "fixture\n", 0o700); @@ -434,6 +435,11 @@ describe("sandbox build context staging", () => { expect( fs.existsSync(path.join(buildCtx, "scripts", "patch-openclaw-device-self-approval.mts")), ).toBe(true); + expect( + fs.existsSync( + path.join(buildCtx, "scripts", "patch-openclaw-shared-state-permissions.mts"), + ), + ).toBe(true); expect( fs.existsSync(path.join(buildCtx, "scripts", "patch-openclaw-device-self-approval.ts")), ).toBe(false); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index ef6d176da3f..6b2d22361cc 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -853,11 +853,12 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { "plugin-runtime-deps", "sandbox", "skills", + "state", "telegram", "wechat", "workspace", ]); - expect(modern.filesAfterCleanup).toEqual(["exec-approvals.json", "update-check.json"]); + expect(modern.filesAfterCleanup).toEqual(["exec-approvals.json"]); expect(modern.cleanup.calls.split("\n").filter(Boolean)).not.toEqual( expect.arrayContaining([expect.stringMatching(/^find /)]), ); @@ -906,8 +907,8 @@ describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { const openclawDir = path.join(sandboxRoot, ".openclaw"); expect(fs.statSync(openclawDir).isDirectory()).toBe(true); expect(fs.statSync(path.join(openclawDir, "exec-approvals.json")).isFile()).toBe(true); - expect(fs.statSync(path.join(openclawDir, "update-check.json")).isFile()).toBe(true); - for (const dir of ["credentials", "devices", "identity", "logs", "telegram"]) { + expect(fs.existsSync(path.join(openclawDir, "update-check.json"))).toBe(false); + for (const dir of ["credentials", "devices", "identity", "logs", "state", "telegram"]) { const stateDir = path.join(openclawDir, dir); expect(fs.statSync(stateDir).isDirectory()).toBe(true); expect(fs.lstatSync(stateDir).isSymbolicLink()).toBe(false); From 098e617ebd467a110e70a83a55193201a8267e2c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 20 Jul 2026 22:17:42 -0700 Subject: [PATCH 05/24] test: satisfy conditional growth policy Signed-off-by: Aaron Erickson --- ...atch-openclaw-shared-state-permissions.mts | 0 test/openclaw-2026-7-startup-compat.test.ts | 26 ++++++----- ...openclaw-real-patched-dist-harness.test.ts | 5 ++- ...law-shared-state-permissions-patch.test.ts | 44 +++++++------------ 4 files changed, 33 insertions(+), 42 deletions(-) mode change 100644 => 100755 scripts/patch-openclaw-shared-state-permissions.mts diff --git a/scripts/patch-openclaw-shared-state-permissions.mts b/scripts/patch-openclaw-shared-state-permissions.mts old mode 100644 new mode 100755 diff --git a/test/openclaw-2026-7-startup-compat.test.ts b/test/openclaw-2026-7-startup-compat.test.ts index a8e7aa5c2ad..ee4555962d7 100644 --- a/test/openclaw-2026-7-startup-compat.test.ts +++ b/test/openclaw-2026-7-startup-compat.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; @@ -30,7 +31,7 @@ function repairUpdateCheck(configDir: string) { function extractShellFunction(source: string, name: string): string { const match = source.match(new RegExp(`${name}\\(\\) \\{([\\s\\S]*?)^\\}`, "m")); - if (!match) throw new Error(`Expected ${name} in scripts/nemoclaw-start.sh`); + assert(match, `Expected ${name} in scripts/nemoclaw-start.sh`); return `${name}() {${match[1]}\n}`; } @@ -125,16 +126,19 @@ describe("OpenClaw 2026.7 startup compatibility", () => { ] as const)("rejects a %s update-check path", (kind) => { const configDir = temporaryConfigDir(); const statePath = path.join(configDir, "update-check.json"); - if (kind === "symlink") { - const target = path.join(path.dirname(configDir), "target.json"); - fs.writeFileSync(target, ""); - fs.symlinkSync(target, statePath); - } else if (kind === "directory") { - fs.mkdirSync(statePath); - } else { - const target = path.join(path.dirname(configDir), "target.json"); - fs.writeFileSync(target, "{}"); - fs.linkSync(target, statePath); + const target = path.join(path.dirname(configDir), "target.json"); + switch (kind) { + case "symlink": + fs.writeFileSync(target, ""); + fs.symlinkSync(target, statePath); + break; + case "directory": + fs.mkdirSync(statePath); + break; + case "hardlink": + fs.writeFileSync(target, "{}"); + fs.linkSync(target, statePath); + break; } const result = repairUpdateCheck(configDir); diff --git a/test/openclaw-real-patched-dist-harness.test.ts b/test/openclaw-real-patched-dist-harness.test.ts index 081d1327d15..e783cea27c1 100644 --- a/test/openclaw-real-patched-dist-harness.test.ts +++ b/test/openclaw-real-patched-dist-harness.test.ts @@ -152,8 +152,9 @@ function resolveRealOpenClawNodeRuntime( }); requireSpawnSuccess(versionProbe, `probe ${REAL_OPENCLAW_NODE_ENV}`); const version = versionProbe.stdout.trim(); - const match = version.match(/^v(\d+)\.(\d+)\.(\d+)$/u); - if (!match) runtimeMismatch(version, "a stable Node version", REAL_OPENCLAW_NODE_ENV); + const match = + version.match(/^v(\d+)\.(\d+)\.(\d+)$/u) ?? + runtimeMismatch(version, "a stable Node version", REAL_OPENCLAW_NODE_ENV); const major = Number(match[1]); const minor = Number(match[2]); const patch = Number(match[3]); diff --git a/test/openclaw-shared-state-permissions-patch.test.ts b/test/openclaw-shared-state-permissions-patch.test.ts index 618e214d821..7e6d1378ec2 100644 --- a/test/openclaw-shared-state-permissions-patch.test.ts +++ b/test/openclaw-shared-state-permissions-patch.test.ts @@ -8,7 +8,6 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; - import { AGENT_MARKER, FILE_STORE_MARKER, @@ -24,6 +23,7 @@ import { patchOpenClawStateMigrationText, SECRET_MARKER, } from "../scripts/patch-openclaw-shared-state-permissions.mts"; +import { restoreEnv } from "./helpers/env-test-helpers"; const PATCH_SCRIPT = path.join( import.meta.dirname, @@ -475,8 +475,7 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { expect(mode(stateDir)).toBe(0o2770); expect(mode(database)).toBe(0o660); } finally { - if (previousMarker === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; - else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousMarker; + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousMarker); fs.rmSync(fixture.root, { recursive: true, force: true }); } }); @@ -524,8 +523,7 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { await runtime.enforcePrivatePathMode(privateDir, defaults.dirMode, "directory"); expect(mode(privateDir)).toBe(0o2770); } finally { - if (previousMarker === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; - else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousMarker; + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousMarker); fs.rmSync(fixture.root, { recursive: true, force: true }); } }); @@ -571,8 +569,7 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { expect(reader.status, reader.stderr).toBe(0); expect(reader.stdout).toBe("shared"); } finally { - if (previousMarker === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; - else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousMarker; + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousMarker); fs.rmSync(fixture.root, { recursive: true, force: true }); } }, @@ -602,8 +599,7 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { runtime.fileStore({ rootDir, private: true, dirMode: 0o750, mode: 0o640 }), ).toMatchObject({ dirMode: 0o750, mode: 0o640, privateMode: true }); } finally { - if (previousMarker === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; - else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousMarker; + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousMarker); fs.rmSync(fixture.root, { recursive: true, force: true }); } }); @@ -630,10 +626,8 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { privateMode: true, }); } finally { - if (previousShared === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; - else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousShared; - if (previousOpenShell === undefined) delete process.env.OPENSHELL_SANDBOX; - else process.env.OPENSHELL_SANDBOX = previousOpenShell; + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousShared); + restoreEnv("OPENSHELL_SANDBOX", previousOpenShell); fs.rmSync(fixture.root, { recursive: true, force: true }); } }); @@ -661,10 +655,8 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { await runtime.ensureModelsFileModeForModelsJson(modelsFile); expect(runtime.chmodCalls).toEqual([]); } finally { - if (previousShared === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; - else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousShared; - if (previousOpenShell === undefined) delete process.env.OPENSHELL_SANDBOX; - else process.env.OPENSHELL_SANDBOX = previousOpenShell; + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousShared); + restoreEnv("OPENSHELL_SANDBOX", previousOpenShell); fs.rmSync(fixture.root, { recursive: true, force: true }); } }); @@ -685,10 +677,8 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { expect(mode(modelsFile)).toBe(0o600); expect(runtime.chmodCalls).toEqual([{ pathname: modelsFile, mode: 0o600 }]); } finally { - if (previousShared === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; - else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousShared; - if (previousOpenShell === undefined) delete process.env.OPENSHELL_SANDBOX; - else process.env.OPENSHELL_SANDBOX = previousOpenShell; + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousShared); + restoreEnv("OPENSHELL_SANDBOX", previousOpenShell); fs.rmSync(fixture.root, { recursive: true, force: true }); } }); @@ -714,10 +704,8 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { warnings: [], }); } finally { - if (previousShared === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; - else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousShared; - if (previousOpenShell === undefined) delete process.env.OPENSHELL_SANDBOX; - else process.env.OPENSHELL_SANDBOX = previousOpenShell; + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousShared); + restoreEnv("OPENSHELL_SANDBOX", previousOpenShell); fs.rmSync(fixture.root, { recursive: true, force: true }); } }); @@ -739,10 +727,8 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { warnings: ["upstream update-check migration ran"], }); } finally { - if (previousShared === undefined) delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; - else process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = previousShared; - if (previousOpenShell === undefined) delete process.env.OPENSHELL_SANDBOX; - else process.env.OPENSHELL_SANDBOX = previousOpenShell; + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousShared); + restoreEnv("OPENSHELL_SANDBOX", previousOpenShell); fs.rmSync(fixture.root, { recursive: true, force: true }); } }); From f4001bd6ce623d9ab0f65c60e13326a876a92633 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 21 Jul 2026 08:32:02 -0400 Subject: [PATCH 06/24] fix(security): close OpenClaw 2026.7.1 validation gaps Signed-off-by: Julie Yaunches --- Dockerfile | 15 ++- .../openclaw-2026.7.1-dependency-review.md | 11 +- scripts/lib/openclaw-npm-remediation.mts | 19 +++- scripts/patch-openclaw-chat-send.mts | 46 +++++--- ...issue-4462-fresh-agent-gateway-snapshot.py | 48 +++++--- test/e2e/live/network-policy.test.ts | 7 +- test/openclaw-2026-7-startup-compat.test.ts | 5 - test/openclaw-chat-send-patch.test.ts | 11 ++ test/openclaw-npm-remediation.test.ts | 16 +++ test/openclaw-state-file-repair.test.ts | 106 ++++++++++++++++++ test/sandbox-provisioning.test.ts | 6 +- 11 files changed, 242 insertions(+), 48 deletions(-) create mode 100644 test/openclaw-state-file-repair.test.ts diff --git a/Dockerfile b/Dockerfile index 0a953aab951..299c0a098af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1317,9 +1317,16 @@ RUN set -eu; \ [ ! -e "$update_check" ] || [ -f "$update_check" ] \ || { echo "ERROR: refusing non-regular OpenClaw update-check state" >&2; exit 1; }; \ rm -f "$update_check"; \ - touch "$config_dir/exec-approvals.json"; \ - chown sandbox:sandbox "$config_dir/exec-approvals.json"; \ - chmod 660 "$config_dir/exec-approvals.json"; \ + exec_approvals="$config_dir/exec-approvals.json"; \ + [ ! -L "$exec_approvals" ] \ + || { echo "ERROR: refusing unsafe OpenClaw state file: $exec_approvals" >&2; exit 1; }; \ + [ ! -e "$exec_approvals" ] || [ -f "$exec_approvals" ] \ + || { echo "ERROR: refusing unsafe OpenClaw state file: $exec_approvals" >&2; exit 1; }; \ + [ ! -e "$exec_approvals" ] || [ "$(stat -c '%h' "$exec_approvals")" = "1" ] \ + || { echo "ERROR: refusing unsafe OpenClaw state file: $exec_approvals" >&2; exit 1; }; \ + touch "$exec_approvals"; \ + chown sandbox:sandbox "$exec_approvals"; \ + chmod 660 "$exec_approvals"; \ for file in \ "$config_dir/state/openclaw.sqlite" \ "$config_dir/state/openclaw.sqlite-wal" \ @@ -1328,6 +1335,8 @@ RUN set -eu; \ [ -e "$file" ] || [ -L "$file" ] || continue; \ [ -f "$file" ] && [ ! -L "$file" ] \ || { echo "ERROR: refusing unsafe OpenClaw state file: $file" >&2; exit 1; }; \ + [ "$(stat -c '%h' "$file")" = "1" ] \ + || { echo "ERROR: refusing unsafe OpenClaw state file: $file" >&2; exit 1; }; \ chown sandbox:sandbox "$file"; \ chmod 660 "$file"; \ done; \ diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 2df297e1239..cec7d259271 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -82,6 +82,9 @@ deterministic remediated package-tree integrity before installation. This canonical tree digest is independent of npm-generated tar metadata, which can vary between npm patch releases without changing package contents. The production plugin installer and `reviewed-npm-audit` use this same function. +The tree hash opens each regular file without following symbolic links and +validates the opened descriptor before it reads the content. This keeps the +metadata and content checks bound to the same file. This remediation is limited to `@openclaw/slack@2026.7.1` and `@openclaw/msteams@2026.7.1`. Remove it when a reviewed stable OpenClaw plugin @@ -108,7 +111,8 @@ The `2026.7.1` dist changed seven reviewed shapes: dispatcher policy; - queued follow-up execution now resolves inbound context before allocating a run id; `scripts/patch-openclaw-chat-send.mts` preserves the submitted run id - at that new boundary; + at that new boundary. It also suppresses the premature empty final event that + the new queue acknowledgment emits before the correlated follow-up completes; - device-token authentication now rejects a requested scope upgrade before the canonical pairing gate can create its pending request. The compatibility patch continues only an exact CLI/operator request limited to @@ -171,6 +175,11 @@ follows: - a root entrypoint starts the `gateway` user with `HOME=/sandbox`, so startup migrations do not probe the inaccessible `/root/.openclaw` path. +During image assembly, the shared-state repair rejects symbolic links, +non-regular entries, and multiply linked files before it changes the ownership +or mode of `exec-approvals.json` or SQLite state. This prevents a stale image +entry from redirecting those mutations to another path or inode. + These repairs run during image build or sandbox startup. They do not change the documented update and rebuild workflow. Regression coverage lives in `test/openclaw-2026-7-startup-compat.test.ts` and diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index 5b4e3add2a9..7198bb2a91e 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -5,11 +5,15 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { + closeSync, + constants, cpSync, existsSync, + fstatSync, lstatSync, mkdirSync, mkdtempSync, + openSync, readdirSync, readFileSync, rmSync, @@ -153,9 +157,18 @@ function hashPackageTree(packageDirectory: string): string { hash.update(`directory\0${relativePath}\0`); visit(absolutePath, relativePath); } else if (stats.isFile()) { - hash.update(`file\0${relativePath}\0${stats.size}\0`); - hash.update(readFileSync(absolutePath)); - hash.update("\0"); + const descriptor = openSync(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const openedStats = fstatSync(descriptor); + if (!openedStats.isFile()) { + throw new Error(`Remediated package tree has unsupported entry ${relativePath}`); + } + hash.update(`file\0${relativePath}\0${openedStats.size}\0`); + hash.update(readFileSync(descriptor)); + hash.update("\0"); + } finally { + closeSync(descriptor); + } } else { throw new Error(`Remediated package tree has unsupported entry ${relativePath}`); } diff --git a/scripts/patch-openclaw-chat-send.mts b/scripts/patch-openclaw-chat-send.mts index 5a525fe7f3b..939f529c545 100755 --- a/scripts/patch-openclaw-chat-send.mts +++ b/scripts/patch-openclaw-chat-send.mts @@ -117,28 +117,44 @@ function patchChatSendTranscriptIdempotency(source: string, file: string): Patch } function patchChatSendEmptyFinal(source: string, file: string): PatchResult { - if (source.includes("suppressing empty final event")) { - return { nextSource: source, status: "already-applied" }; + let nextSource = source; + if (!nextSource.includes("suppressing empty final event")) { + nextSource = nextSource.replace( + /\n(\s*)broadcastChatFinal\(\{\n(\s*)context,\n\s*runId: clientRunId,\n\s*sessionKey,\n(\s*agentId,\n)?\s*message\n\s*\}\);/, + (_match, outerIndent, innerIndent, agentIdLine) => + `\n${outerIndent}if (message) broadcastChatFinal({\n` + + `${innerIndent}context,\n` + + `${innerIndent}runId: clientRunId,\n` + + `${innerIndent}sessionKey,\n` + + (agentIdLine || "") + + `${innerIndent}message\n` + + `${outerIndent}}); else context.logGateway.warn("webchat chat.send completed without visible assistant reply; suppressing empty final event (nemoclaw #2603/#3145)");`, + ); } - const nextSource = source.replace( - /\n(\s*)broadcastChatFinal\(\{\n(\s*)context,\n\s*runId: clientRunId,\n\s*sessionKey,\n(\s*agentId,\n)?\s*message\n\s*\}\);/, - (_match, outerIndent, innerIndent, agentIdLine) => - `\n${outerIndent}if (message) broadcastChatFinal({\n` + - `${innerIndent}context,\n` + - `${innerIndent}runId: clientRunId,\n` + - `${innerIndent}sessionKey,\n` + - (agentIdLine || "") + - `${innerIndent}message\n` + - `${outerIndent}}); else context.logGateway.warn("webchat chat.send completed without visible assistant reply; suppressing empty final event (nemoclaw #2603/#3145)");`, - ); - if (nextSource === source) { + if ( + nextSource.includes("queuedFollowupEnqueued") && + !nextSource.includes("suppressing premature queued followup final event") + ) { + nextSource = nextSource.replace( + /if \(queuedFollowupEnqueued && !context\.chatAbortedRuns\.has\(clientRunId\)\) broadcastChatFinal\(\{\n\s*context,\n\s*runId: clientRunId,\n\s*sessionKey,\n\s*agentId\n\s*\}\);/, + 'if (queuedFollowupEnqueued && !context.chatAbortedRuns.has(clientRunId)) context.logGateway.warn("webchat chat.send queued a correlated followup; suppressing premature queued followup final event (nemoclaw #2603/#3145)");', + ); + } + const missingVisibleFinalPatch = !nextSource.includes("suppressing empty final event"); + const missingQueuedFinalPatch = + nextSource.includes("queuedFollowupEnqueued") && + !nextSource.includes("suppressing premature queued followup final event"); + if (missingVisibleFinalPatch || missingQueuedFinalPatch) { return { nextSource: source, status: "no-match", error: `OpenClaw chat.send empty-final shape not recognized in ${file}`, }; } - return { nextSource, status: "would-apply" }; + return { + nextSource, + status: nextSource === source ? "already-applied" : "would-apply", + }; } function patchGetReplyFollowupRunId(source: string, file: string): PatchResult { diff --git a/test/e2e/lib/issue-4462-fresh-agent-gateway-snapshot.py b/test/e2e/lib/issue-4462-fresh-agent-gateway-snapshot.py index 7ac43eff381..b9ee8abd358 100644 --- a/test/e2e/lib/issue-4462-fresh-agent-gateway-snapshot.py +++ b/test/e2e/lib/issue-4462-fresh-agent-gateway-snapshot.py @@ -37,25 +37,39 @@ def gateway_completed_runs(): device_id = norm(identity.get("deviceId")) if not device_id: raise SystemExit("CLI identity has no deviceId") -pending = [ - value - for value in load_map(root / "devices" / "pending.json").values() - if isinstance(value, dict) -] -paired = [ - value - for value in load_map(root / "devices" / "paired.json").values() - if isinstance(value, dict) -] -paired_cli = [ - value - for value in paired - if value.get("clientId") == "cli" and value.get("clientMode") == "cli" -] -matching = [value for value in paired_cli if norm(value.get("deviceId")) == device_id] +pairing_deadline = time.monotonic() + 10 +while True: + pending = [ + value + for value in load_map(root / "devices" / "pending.json").values() + if isinstance(value, dict) + ] + paired = [ + value + for value in load_map(root / "devices" / "paired.json").values() + if isinstance(value, dict) + ] + paired_cli = [ + value + for value in paired + if value.get("clientId") == "cli" and value.get("clientMode") == "cli" + ] + matching = [value for value in paired_cli if norm(value.get("deviceId")) == device_id] + if len(matching) == 1 or time.monotonic() >= pairing_deadline: + break + time.sleep(0.1) if len(matching) != 1: + observed = [ + { + "clientId": norm(value.get("clientId")), + "clientMode": norm(value.get("clientMode")), + "deviceIdMatches": norm(value.get("deviceId")) == device_id, + } + for value in paired + ] raise SystemExit( - f"CLI identity must match exactly one paired device, found {len(matching)}" + "CLI identity must match exactly one paired device, " + f"found {len(matching)}; observed={json.dumps(observed, sort_keys=True)}" ) device = matching[0] tokens = device.get("tokens") diff --git a/test/e2e/live/network-policy.test.ts b/test/e2e/live/network-policy.test.ts index 44c8966c46f..5e2d29fdfa7 100644 --- a/test/e2e/live/network-policy.test.ts +++ b/test/e2e/live/network-policy.test.ts @@ -380,7 +380,12 @@ function fail(code, detail) { function findDistFile(prefix) { const candidates = fs .readdirSync(distDir) - .filter((name) => name.startsWith(prefix) && name.endsWith(".js")) + .filter( + (name) => + name.startsWith(prefix) && + !name.startsWith(prefix + "serve-config-") && + name.endsWith(".js"), + ) .sort(); if (candidates.length !== 1) { throw new Error( diff --git a/test/openclaw-2026-7-startup-compat.test.ts b/test/openclaw-2026-7-startup-compat.test.ts index ee4555962d7..7310d07400c 100644 --- a/test/openclaw-2026-7-startup-compat.test.ts +++ b/test/openclaw-2026-7-startup-compat.test.ts @@ -11,7 +11,6 @@ import { afterEach, describe, expect, it } from "vitest"; const ROOT = path.resolve(import.meta.dirname, ".."); const NORMALIZER = path.join(ROOT, "scripts", "lib", "normalize_mutable_config_perms.py"); const START_SCRIPT = path.join(ROOT, "scripts", "nemoclaw-start.sh"); -const DOCKERFILE = path.join(ROOT, "Dockerfile"); const temporaryRoots: string[] = []; function temporaryConfigDir(): string { @@ -42,10 +41,6 @@ afterEach(() => { }); describe("OpenClaw 2026.7 startup compatibility", () => { - it("marks direct NemoClaw containers for split-user shared state", () => { - expect(fs.readFileSync(DOCKERFILE, "utf-8")).toContain("ENV NEMOCLAW_OPENCLAW_SHARED_STATE=1"); - }); - it("removes a zero-byte legacy update-check file", () => { const configDir = temporaryConfigDir(); const statePath = path.join(configDir, "update-check.json"); diff --git a/test/openclaw-chat-send-patch.test.ts b/test/openclaw-chat-send-patch.test.ts index 739cb1df767..9deb3eb7228 100644 --- a/test/openclaw-chat-send-patch.test.ts +++ b/test/openclaw-chat-send-patch.test.ts @@ -57,6 +57,13 @@ function writeChatSendFixture(dist: string): string { " message", " });", " }", + " let queuedFollowupEnqueued = true;", + " if (queuedFollowupEnqueued && !context.chatAbortedRuns.has(clientRunId)) broadcastChatFinal({", + " context,", + " runId: clientRunId,", + " sessionKey,", + " agentId", + " });", " });", " }", "};", @@ -664,6 +671,7 @@ describe("OpenClaw chat.send compatibility patch", () => { expect(patched).toContain("idempotencyKey: clientRunId"); expect(patched).toContain("if (message) broadcastChatFinal({"); expect(patched).toContain("suppressing empty final event"); + expect(patched).toContain("suppressing premature queued followup final event"); const patchedFollowup = fs.readFileSync(followupFixture, "utf-8"); expect(patchedFollowup).toContain( @@ -686,6 +694,9 @@ describe("OpenClaw chat.send compatibility patch", () => { expect(rerunPatched.match(/context\.addChatRun\(runId/g)).toHaveLength(1); expect(rerunPatched.match(/idempotencyKey: clientRunId/g)).toHaveLength(1); expect(rerunPatched.match(/suppressing empty final event/g)).toHaveLength(1); + expect(rerunPatched.match(/suppressing premature queued followup final event/g)).toHaveLength( + 1, + ); const rerunPatchedFollowup = fs.readFileSync(followupFixture, "utf-8"); expect( rerunPatchedFollowup.match(/preserve chat\.send run ids in followup queue/g), diff --git a/test/openclaw-npm-remediation.test.ts b/test/openclaw-npm-remediation.test.ts index d0f74894ccf..f7158f0dd8a 100644 --- a/test/openclaw-npm-remediation.test.ts +++ b/test/openclaw-npm-remediation.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -62,6 +63,15 @@ function readJson(file: string): T { return JSON.parse(readFileSync(file, "utf-8")) as T; } +function readPackageField(directory: string, field: string): T { + const result = spawnSync("npm", ["pkg", "get", field, "--json"], { + cwd: directory, + encoding: "utf-8", + }); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout) as T; +} + afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }); @@ -74,6 +84,12 @@ describe("OpenClaw npm remediation", () => { patchOpenClawPluginPackageGraph(directory, "@openclaw/slack@2026.7.1"); + expect(readPackageField(directory, "dependencies.axios")).toBe("1.18.0"); + expect(readPackageField(directory, "bundledDependencies")).toEqual([ + "@slack/bolt", + "axios", + ]); + const shrinkwrap = readJson<{ packages: Record }>; }>(path.join(directory, "npm-shrinkwrap.json")); diff --git a/test/openclaw-state-file-repair.test.ts b/test/openclaw-state-file-repair.test.ts new file mode 100644 index 00000000000..993786598b4 --- /dev/null +++ b/test/openclaw-state-file-repair.test.ts @@ -0,0 +1,106 @@ +// 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 { expect, it } from "vitest"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const DOCKERFILE = path.join(ROOT, "Dockerfile"); + +function openclawStateRepairCommand(): string { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); + const startMarker = "# Flatten stale published base images"; + const endMarker = "# Stale-base fallback for the gateway/root-in-sandbox-group setup"; + const start = dockerfile.indexOf(startMarker); + const end = dockerfile.indexOf(endMarker, start); + const runIndex = dockerfile.indexOf("RUN ", start); + if (start === -1 || end === -1 || runIndex === -1 || runIndex > end) { + throw new Error("Expected the OpenClaw state repair RUN instruction"); + } + const runLines: string[] = []; + for (const line of dockerfile.slice(runIndex, end).split("\n")) { + runLines.push(line); + if (!line.trimEnd().endsWith("\\")) break; + } + if ((runLines.at(-1) ?? "").trimEnd().endsWith("\\")) { + throw new Error("Expected the complete OpenClaw state repair RUN instruction"); + } + return runLines + .join("\n") + .trim() + .replace(/^RUN\s+/, "") + .replace(/\\\n/g, " "); +} + +function runUnsafeStateTarget( + relativeTarget: string, + setup: (target: string, temporaryRoot: string) => void, +) { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-unsafe-state-")); + const sandboxRoot = path.join(temporaryRoot, "sandbox"); + const openclawDir = path.join(sandboxRoot, ".openclaw"); + const target = path.join(openclawDir, relativeTarget); + fs.mkdirSync(path.dirname(target), { recursive: true }); + setup(target, temporaryRoot); + const command = openclawStateRepairCommand() + .replaceAll("/sandbox/.openclaw-data", path.join(sandboxRoot, ".openclaw-data")) + .replaceAll("/sandbox/.openclaw", openclawDir) + .replaceAll("/sandbox/.npm", path.join(sandboxRoot, ".npm")) + .replaceAll("/tmp/nemoclaw-legacy-openclaw-layout", path.join(temporaryRoot, "legacy-marker")) + .replaceAll("/root/.npm", path.join(temporaryRoot, "root-npm")); + const script = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'install() { local target="${*: -1}"; mkdir -p "$target"; }', + "chown() { :; }", + 'stat() { command stat -f "%l" "${@: -1}"; }', + command, + ].join("\n"); + const scriptPath = path.join(temporaryRoot, "run-state-repair.sh"); + fs.writeFileSync(scriptPath, script, { mode: 0o700 }); + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + return { result, target, temporaryRoot }; +} + +it.each([ + { + label: "symlinked exec approvals", + relativeTarget: "exec-approvals.json", + setup: (target: string, temporaryRoot: string) => { + const outside = path.join(temporaryRoot, "outside-exec-approvals.json"); + fs.writeFileSync(outside, "{}\n"); + fs.symlinkSync(outside, target); + }, + }, + { + label: "hard-linked exec approvals", + relativeTarget: "exec-approvals.json", + setup: (target: string, temporaryRoot: string) => { + const outside = path.join(temporaryRoot, "outside-exec-approvals.json"); + fs.writeFileSync(outside, "{}\n"); + fs.linkSync(outside, target); + }, + }, + { + label: "hard-linked SQLite state", + relativeTarget: "state/openclaw.sqlite", + setup: (target: string, temporaryRoot: string) => { + const outside = path.join(temporaryRoot, "outside-openclaw.sqlite"); + fs.writeFileSync(outside, "sqlite-fixture"); + fs.linkSync(outside, target); + }, + }, +])("rejects $label before changing its inode", ({ relativeTarget, setup }) => { + const fixture = runUnsafeStateTarget(relativeTarget, setup); + try { + expect(fixture.result.status).not.toBe(0); + expect(fixture.result.stderr).toContain( + `ERROR: refusing unsafe OpenClaw state file: ${fixture.target}`, + ); + } finally { + fs.rmSync(fixture.temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 6b2d22361cc..a61223e738a 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -262,13 +262,13 @@ function runOpenclawStaleGroupFallback() { } describe("sandbox provisioning: runtime npm online state", () => { - it("replays the Dockerfile ENV directives so the runtime image inherits NPM_CONFIG_OFFLINE=false", () => { + it("replays the Dockerfile ENV directives for runtime npm and shared OpenClaw state", () => { const exports = collectDockerfileEnvExports(DOCKERFILE); const probe = [ "#!/usr/bin/env bash", "set -eo pipefail", ...exports, - 'printf "%s\\n" "${NPM_CONFIG_OFFLINE:-unset}"', + 'printf "%s\\n" "${NPM_CONFIG_OFFLINE:-unset}" "${NEMOCLAW_OPENCLAW_SHARED_STATE:-unset}"', ].join("\n"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-npm-online-")); const scriptPath = path.join(tmp, "replay.sh"); @@ -276,7 +276,7 @@ describe("sandbox provisioning: runtime npm online state", () => { fs.writeFileSync(scriptPath, probe, { mode: 0o700 }); const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); expect(result.status, `stderr: ${result.stderr}`).toBe(0); - expect(result.stdout.trim()).toBe("false"); + expect(result.stdout.trim().split("\n")).toEqual(["false", "1"]); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } From 1359aa775e160a3cba9c911d0f241e030d914f22 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 21 Jul 2026 08:35:45 -0400 Subject: [PATCH 07/24] fix(state): fail closed on sqlite backup errors Signed-off-by: Julie Yaunches --- src/lib/state/sandbox.ts | 5 ++--- test/hermes-kanban-snapshot.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index d95ff1cb49b..54984baa2cd 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -787,7 +787,7 @@ const SQLITE_BACKUP_PY = [ " src_conn.close()", ].join("\n"); -function buildStateFileBackupCommand(dir: string, spec: StateFileSpec): string { +export function buildStateFileBackupCommand(dir: string, spec: StateFileSpec): string { const remotePath = stateFileRemotePath(dir, spec.path); const quotedRemotePath = shellQuote(remotePath); if (spec.strategy === "sqlite_backup") { @@ -799,8 +799,7 @@ function buildStateFileBackupCommand(dir: string, spec: StateFileSpec): string { '[ "${hardlink_count:-0}" = "0" ] || { echo "hard-linked sqlite state file rejected: $src" >&2; exit 11; }', 'tmp="$(mktemp /tmp/nemoclaw-sqlite-backup.XXXXXX)"', "trap 'rm -f \"$tmp\"' EXIT", - `python3 -c ${shellQuote(SQLITE_BACKUP_PY)} "$src" "$tmp"`, - 'cat -- "$tmp"', + `python3 -c ${shellQuote(SQLITE_BACKUP_PY)} "$src" "$tmp" && cat -- "$tmp"`, ].join("; "); } diff --git a/test/hermes-kanban-snapshot.test.ts b/test/hermes-kanban-snapshot.test.ts index 7843ca8bd0b..2aef3f6d39f 100644 --- a/test/hermes-kanban-snapshot.test.ts +++ b/test/hermes-kanban-snapshot.test.ts @@ -1,6 +1,7 @@ // 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"; @@ -45,6 +46,32 @@ function writeHermesRegistry(): void { ); } +it("fails the SQLite state backup when the online backup command fails (#7095)", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sqlite-backup-failure-")); + try { + const binDir = path.join(fixture, "bin"); + const sourceDir = path.join(fixture, "state"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync(path.join(sourceDir, "kanban.db"), "source database\n"); + writeExecutable(path.join(binDir, "python3"), "#!/bin/sh\nexit 9\n"); + + const command = sandboxState.buildStateFileBackupCommand(sourceDir, { + path: "kanban.db", + strategy: "sqlite_backup", + }); + const result = spawnSync("sh", ["-c", command], { + env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH || ""}` }, + encoding: null, + }); + + expect(result.status).toBe(9); + expect(result.stdout).toHaveLength(0); + } finally { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); + it("preserves only the Hermes default-board database across rebuilds (#7095)", () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-kanban-state-")); const oldPath = process.env.PATH; From 0a182f787e63200f880f5f4b49762a790c74a05f Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 21 Jul 2026 08:41:04 -0400 Subject: [PATCH 08/24] test: satisfy OpenClaw repair conditional policy Signed-off-by: Julie Yaunches --- test/openclaw-state-file-repair.test.ts | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/test/openclaw-state-file-repair.test.ts b/test/openclaw-state-file-repair.test.ts index 993786598b4..ab7062de8d9 100644 --- a/test/openclaw-state-file-repair.test.ts +++ b/test/openclaw-state-file-repair.test.ts @@ -12,27 +12,10 @@ const DOCKERFILE = path.join(ROOT, "Dockerfile"); function openclawStateRepairCommand(): string { const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); - const startMarker = "# Flatten stale published base images"; - const endMarker = "# Stale-base fallback for the gateway/root-in-sandbox-group setup"; - const start = dockerfile.indexOf(startMarker); - const end = dockerfile.indexOf(endMarker, start); - const runIndex = dockerfile.indexOf("RUN ", start); - if (start === -1 || end === -1 || runIndex === -1 || runIndex > end) { - throw new Error("Expected the OpenClaw state repair RUN instruction"); - } - const runLines: string[] = []; - for (const line of dockerfile.slice(runIndex, end).split("\n")) { - runLines.push(line); - if (!line.trimEnd().endsWith("\\")) break; - } - if ((runLines.at(-1) ?? "").trimEnd().endsWith("\\")) { - throw new Error("Expected the complete OpenClaw state repair RUN instruction"); - } - return runLines - .join("\n") - .trim() - .replace(/^RUN\s+/, "") - .replace(/\\\n/g, " "); + const [, instruction] = dockerfile.match( + /# Flatten stale published base images[\s\S]*?\nRUN ([\s\S]*?)\n\n# Stale-base fallback for the gateway\/root-in-sandbox-group setup/, + )!; + return instruction.trim().replace(/\\\n/g, " "); } function runUnsafeStateTarget( From 457fe5f1a7291c9310cfb574aa99388d423098cb Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 21 Jul 2026 08:54:50 -0400 Subject: [PATCH 09/24] fix(openclaw): restore pairing and rebuild recovery Signed-off-by: Julie Yaunches --- .../security/openclaw-2026.7.1-dependency-review.md | 5 +++++ scripts/nemoclaw-start.sh | 13 +++++++------ .../onboard/authoritative-rebuild-target.test.ts | 3 ++- src/lib/onboard/authoritative-rebuild-target.ts | 1 + test/nemoclaw-start.test.ts | 2 +- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index cec7d259271..7d3889aed77 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -151,6 +151,11 @@ The `2026.7.1` dist changed seven reviewed shapes: `scripts/patch-openclaw-device-self-approval.mts` remains required. Its new shape recognizers preserve the bounded stored-device credential flow and keep the canonical `approveDevicePairing` transaction fail closed. +The startup auto-pair watcher removes the gateway URL, port, and shared token +from both `devices list` and `devices approve`. OpenClaw `2026.7.1` otherwise +accepts the shared token for the list call without creating the canonical CLI +pairing request. Local device authentication makes the list call initiate that +pairing flow before the bounded approval runs. ## Gateway Startup Migration Compatibility diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 786b45670bf..e46898e91dd 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2539,8 +2539,9 @@ start_auto_pair() { # The gateway must retain NemoClaw's private-interface URL, but the watcher # is an ordinary OpenClaw CLI client. Source the trusted runtime environment # in this child only so an injected private URL is removed before the first - # `devices list`. OpenClaw can then complete its local-loopback pairing - # bootstrap before this unchanged watcher starts approving bounded requests. + # `devices list`. The list call below also drops shared gateway auth so + # OpenClaw must complete its local-loopback device pairing bootstrap before + # this watcher starts approving bounded requests. # An explicit URL override is preserved by write_runtime_shell_env(). ( if [ -r "$_RUNTIME_SHELL_ENV_FILE" ]; then @@ -2875,9 +2876,9 @@ def brief_child_error(out, err): # Workaround boundary (NemoClaw#4462): the watcher child sources the trusted # runtime environment, so list calls resolve the same live gateway through -# local loopback instead of the injected private-interface URL. Approval calls -# additionally drop the gateway env triplet so OpenClaw must use the local -# device token. The reviewed 2026.7.1 dist patch requests only +# local loopback instead of the injected private-interface URL. List and +# approval calls additionally drop the gateway env triplet so OpenClaw must +# use local device auth. The reviewed 2026.7.1 dist patch requests only # operator.pairing for a complete bounded CLI self-upgrade and forces the # existing local-only stored-device-auth path so a shared token reloaded from # config cannot win authentication. The gateway then validates and commits in @@ -2938,7 +2939,7 @@ while time.time() < DEADLINE: if not SLOW_MODE and time.time() >= FAST_DEADLINE: SLOW_MODE = True print(f'[auto-pair] fast-mode deadline reached; switching to slow-mode approvals={APPROVED}') - rc, out, err = run(OPENCLAW, 'devices', 'list', '--json') + rc, out, err = run(OPENCLAW, 'devices', 'list', '--json', strip_gateway_env=True) if rc != 0 or not out: initial_request_id = pairing_required_request_id(out, err) if ( diff --git a/src/lib/onboard/authoritative-rebuild-target.test.ts b/src/lib/onboard/authoritative-rebuild-target.test.ts index ead576b482d..f438c3ae338 100644 --- a/src/lib/onboard/authoritative-rebuild-target.test.ts +++ b/src/lib/onboard/authoritative-rebuild-target.test.ts @@ -148,6 +148,7 @@ describe("prepared provider reconfiguration handoff", () => { provider: "compatible-endpoint", model: "nvidia/model", endpointUrl: "https://inference.example.test/v1", + endpointSource: "onboard", preferredInferenceApi: "openai-completions", source: "registry", }, @@ -163,7 +164,7 @@ describe("prepared provider reconfiguration handoff", () => { }; const activated = rebuildProviderFlowOptions( - { ...authorizedOptions, providerRecoveryReceipt: receipt }, + { ...authorizedOptions, endpointSource: "onboard", providerRecoveryReceipt: receipt }, flowContext, ); expect(activated.providerRecoveryReceipt?.sessionId).toBe("sess-alpha"); diff --git a/src/lib/onboard/authoritative-rebuild-target.ts b/src/lib/onboard/authoritative-rebuild-target.ts index 402f50ec54a..b3e5cd6baba 100644 --- a/src/lib/onboard/authoritative-rebuild-target.ts +++ b/src/lib/onboard/authoritative-rebuild-target.ts @@ -156,6 +156,7 @@ export function rebuildProviderFlowOptions( provider: target.provider, model: target.model, endpointUrl: target.endpointUrl ?? null, + endpointSource: opts.endpointSource ?? null, preferredInferenceApi: target.preferredInferenceApi ?? "", source: "registry", }, diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index daa2fb9a6c6..d46c496ae66 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -1624,7 +1624,7 @@ exit 2 "ok-agent-cli", ]); const envLogLines = fs.readFileSync(envLog, "utf-8").trim().split("\n"); - expect(envLogLines).toContain("list:ws://127.0.0.1:18789:18789:test-gateway-token"); + expect(envLogLines).toContain("list:unset:unset:unset"); expect(envLogLines).toContain("approve:ok-browser:unset:unset:unset"); expect(envLogLines).toContain("approve:ok-agent-cli:unset:unset:unset"); expect(envLogLines).not.toContain("approve:ok-webchat:unset:unset:unset"); From 097b4f1a6c5a1c33b9001dad1d26c00a0a7b197d Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Tue, 21 Jul 2026 09:28:37 -0400 Subject: [PATCH 10/24] fix(security): preserve private OpenClaw stores Signed-off-by: Julie Yaunches --- Dockerfile | 9 +- .../openclaw-2026.7.1-dependency-review.md | 33 +-- scripts/nemoclaw-start.sh | 38 ++- .../patch-openclaw-device-self-approval.mts | 38 +++ ...atch-openclaw-shared-state-permissions.mts | 232 +----------------- ...ckerfile-remote-dashboard-bind-contract.ts | 1 + ...claw-device-self-approval-patch-harness.ts | 23 ++ ...penclaw-real-device-self-approval-proof.ts | 16 +- test/nemoclaw-start.test.ts | 6 +- test/openclaw-dependency-review.test.ts | 5 +- ...penclaw-device-self-approval-patch.test.ts | 35 ++- ...openclaw-real-patched-dist-harness.test.ts | 54 +++- ...law-shared-state-permissions-patch.test.ts | 113 +++------ 13 files changed, 246 insertions(+), 357 deletions(-) diff --git a/Dockerfile b/Dockerfile index 299c0a098af..ddd2744c729 100644 --- a/Dockerfile +++ b/Dockerfile @@ -795,10 +795,11 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-tool- # databases, but hardens them to owner-only modes on every open. NemoClaw runs # the CLI and gateway as separate users in the sandbox group, so use # group-shared modes inside the NemoClaw image or an OpenShell sandbox. The -# patch keeps private-store directories setgid, avoids a non-owner chmod when -# the inherited mode is already safe, keeps generated models files readable by -# the shared group, and ignores the obsolete update-check cache migration that -# cannot archive across a shields-protected parent. +# patch keeps generic credential and identity stores owner-only, avoids a +# non-owner chmod when a reviewed shared database mode is already safe, keeps +# generated models files readable by the shared group, and ignores the obsolete +# update-check cache migration that cannot archive across a shields-protected +# parent. # # Removal criteria: drop when upstream OpenClaw supports a split-user, # group-shared state databases and split-user cache migrations without diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 7d3889aed77..07c1f65c721 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -131,17 +131,15 @@ The `2026.7.1` dist changed seven reviewed shapes: narrowed environment inherit these markers from the gateway process. It skips `chmod` only when the existing mode already matches and rejects an unexpected or ambiguous compiled-dist shape; -- private file-store writes now reapply owner-only defaults to mutable agent - and identity paths. The same image marker selects setgid `2770` directories and - `0660` files, while preserving OpenClaw's path containment, symbolic-link, - pinned-write, and file-identity checks. An absent marker retains upstream - owner-only modes. Setgid preserves the shared `sandbox` group when either the - CLI or gateway creates a new private-store file; -- generated `models.json` and plugin catalog paths reapply `0600` after the - private file-store write. Under the validated NemoClaw marker, the compiled - models-config patch keeps these files at `0660` and skips a non-owner `chmod` - when the inherited mode is already correct. Outside NemoClaw it preserves - the upstream `0600` behavior; +- generic private file stores remain owner-only at upstream `0700` directory + and `0600` file modes, including device identity, device authentication, and + credential-profile paths. The shared-state marker does not broaden these + security boundaries to the sandbox group; +- generated `models.json` is the reviewed exception to the generic private-store + rule. Under the validated NemoClaw marker, the compiled models-config patch + keeps this non-secret provider configuration at `0660` and skips a non-owner + `chmod` when the inherited mode is already correct. Outside NemoClaw it + preserves the upstream `0600` behavior; - the legacy update-check migration is skipped only under the same validated NemoClaw or OpenShell marker. This state contains polling, notification, and auto-install cache for an OpenClaw version that NemoClaw pins in the image; @@ -151,11 +149,14 @@ The `2026.7.1` dist changed seven reviewed shapes: `scripts/patch-openclaw-device-self-approval.mts` remains required. Its new shape recognizers preserve the bounded stored-device credential flow and keep the canonical `approveDevicePairing` transaction fail closed. -The startup auto-pair watcher removes the gateway URL, port, and shared token -from both `devices list` and `devices approve`. OpenClaw `2026.7.1` otherwise -accepts the shared token for the list call without creating the canonical CLI -pairing request. Local device authentication makes the list call initiate that -pairing flow before the bounded approval runs. +Until the initial `devices list` succeeds, the startup auto-pair watcher keeps +the loopback shared token and sets a child-only marker. The compiled +gateway-call patch uses that marker only to retain the CLI device identity that +OpenClaw `2026.7.1` otherwise omits for loopback shared-token calls. OpenClaw +then performs its canonical silent local-pairing transaction and issues the +stored device token. After bootstrap, list calls and every `devices approve` +remove the gateway URL, port, and shared token so the bounded approval flow uses +that device credential. ## Gateway Startup Migration Compatibility diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index e46898e91dd..51f2462f3f9 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2539,9 +2539,9 @@ start_auto_pair() { # The gateway must retain NemoClaw's private-interface URL, but the watcher # is an ordinary OpenClaw CLI client. Source the trusted runtime environment # in this child only so an injected private URL is removed before the first - # `devices list`. The list call below also drops shared gateway auth so - # OpenClaw must complete its local-loopback device pairing bootstrap before - # this watcher starts approving bounded requests. + # `devices list`. The first list call keeps shared gateway auth but uses the + # reviewed child-only marker to retain CLI identity, allowing OpenClaw's + # canonical local-loopback pairing bootstrap. Later calls use device auth. # An explicit URL override is preserved by write_runtime_shell_env(). ( if [ -r "$_RUNTIME_SHELL_ENV_FILE" ]; then @@ -2663,6 +2663,7 @@ QUIET_POLLS = 0 APPROVED = 0 SLOW_MODE = False HANDLED = set() # Track rejected/approved requestIds to avoid reprocessing +PAIRING_BOOTSTRAPPED = False # SECURITY NOTE: clientId/clientMode are client-supplied and spoofable # (the gateway stores connectParams.client.id verbatim). The policy requires # an explicit known clientId and never trusts an allowlisted mode by itself. @@ -2877,14 +2878,14 @@ def brief_child_error(out, err): # Workaround boundary (NemoClaw#4462): the watcher child sources the trusted # runtime environment, so list calls resolve the same live gateway through # local loopback instead of the injected private-interface URL. List and -# approval calls additionally drop the gateway env triplet so OpenClaw must -# use local device auth. The reviewed 2026.7.1 dist patch requests only -# operator.pairing for a complete bounded CLI self-upgrade and forces the -# existing local-only stored-device-auth path so a shared token reloaded from -# config cannot win authentication. The gateway then validates and commits in -# OpenClaw's canonical locked pairing writer. Remove both pieces when upstream -# supports that flow. -def run(*args, strip_gateway_env=False): +# The first successful list call retains the loopback shared token and sets a +# private child marker. The reviewed 2026.7.1 dist patch uses that marker only +# to retain the CLI device identity that OpenClaw otherwise omits for loopback +# shared-token calls, so OpenClaw can perform its canonical silent local +# pairing transaction. Later list and approval calls drop the gateway env +# triplet and use the stored device credential. Remove both pieces when +# upstream supports that flow. +def run(*args, strip_gateway_env=False, force_device_pairing=False): # Bound every openclaw CLI invocation so a wedged child cannot pin # the watcher beyond DEADLINE (CodeRabbit #4292): subprocess.run with # no timeout would hold a hung `openclaw devices list/approve` past @@ -2892,6 +2893,9 @@ def run(*args, strip_gateway_env=False): env = None if strip_gateway_env: env = gateway_approval_env(os.environ) + elif force_device_pairing: + env = dict(os.environ) + env['NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING'] = '1' try: proc = subprocess.run( args, capture_output=True, text=True, timeout=RUN_TIMEOUT_SECS, env=env, @@ -2939,7 +2943,14 @@ while time.time() < DEADLINE: if not SLOW_MODE and time.time() >= FAST_DEADLINE: SLOW_MODE = True print(f'[auto-pair] fast-mode deadline reached; switching to slow-mode approvals={APPROVED}') - rc, out, err = run(OPENCLAW, 'devices', 'list', '--json', strip_gateway_env=True) + rc, out, err = run( + OPENCLAW, + 'devices', + 'list', + '--json', + strip_gateway_env=PAIRING_BOOTSTRAPPED, + force_device_pairing=not PAIRING_BOOTSTRAPPED, + ) if rc != 0 or not out: initial_request_id = pairing_required_request_id(out, err) if ( @@ -2962,6 +2973,9 @@ while time.time() < DEADLINE: print(f'[auto-pair] initial CLI approve failed request={initial_request_id}: {failure}') sleep_for_next_poll(SLOW_INTERVAL if SLOW_MODE else 1, productive=False) continue + if not PAIRING_BOOTSTRAPPED: + PAIRING_BOOTSTRAPPED = True + print('[auto-pair] loopback CLI pairing bootstrap completed') try: data = json.loads(out) except Exception: diff --git a/scripts/patch-openclaw-device-self-approval.mts b/scripts/patch-openclaw-device-self-approval.mts index 690ea654ba1..de91c0e8df4 100644 --- a/scripts/patch-openclaw-device-self-approval.mts +++ b/scripts/patch-openclaw-device-self-approval.mts @@ -39,6 +39,7 @@ const CLI_APPROVE_MARKER = const CLI_SCOPE_MARKER = "nemoclaw: reach gateway for bounded same-device scope approval"; const CLI_RETRY_MARKER = "nemoclaw: keep bounded stored device auth fail closed"; const CLI_LIST_MARKER = "nemoclaw: preflight bounded stored device auth before live pairing list"; +const CALL_FORCE_IDENTITY_MARKER = "nemoclaw: force device identity for loopback pairing bootstrap"; const CLI_APPLIED_MARKERS = [ CLI_MARKER, CLI_APPROVE_MARKER, @@ -63,6 +64,16 @@ const CLI_SELECTOR_DEPENDENCIES = [ "listDevicePairing", ] as const; +const CALL_OMIT_IDENTITY_TARGET = [ + "function shouldOmitDeviceIdentityForGatewayCall(params) {", + "\tconst mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI;", +].join("\n"); +const CALL_OMIT_IDENTITY_REPLACEMENT = [ + "function shouldOmitDeviceIdentityForGatewayCall(params) {", + `\tif (process.env.NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING === "1") return false; // ${CALL_FORCE_IDENTITY_MARKER} (#4462)`, + "\tconst mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI;", +].join("\n"); + type PatchStatus = "already-applied" | "no-match" | "would-apply"; interface ReplacementResult { @@ -799,6 +810,33 @@ const STATE_APPROVAL_PERSIST_REPLACEMENT = [ ].join("\n"); const FILE_SPECS: FileSpec[] = [ + { + id: "gateway-call-device-identity", + label: "gateway call device-identity runtime", + marker: CALL_FORCE_IDENTITY_MARKER, + selector(source) { + return ( + source.includes("function shouldOmitDeviceIdentityForGatewayCall(params) {") && + source.includes("const isLocalCliSharedAuth =") && + (source.includes(CALL_OMIT_IDENTITY_TARGET) || source.includes(CALL_FORCE_IDENTITY_MARKER)) + ); + }, + patch(source, file) { + if (source.includes(CALL_FORCE_IDENTITY_MARKER)) { + return { source, status: "already-applied" }; + } + const result = replaceExactlyOnce( + source, + CALL_OMIT_IDENTITY_TARGET, + CALL_OMIT_IDENTITY_REPLACEMENT, + "gateway call device-identity omission target", + file, + ); + return result.error + ? { source, status: "no-match", error: result.error } + : { source: result.source, status: "would-apply" }; + }, + }, { id: "devices-cli", label: "devices CLI approval runtime", diff --git a/scripts/patch-openclaw-shared-state-permissions.mts b/scripts/patch-openclaw-shared-state-permissions.mts index 0b8670e4c8e..26a9df235b2 100755 --- a/scripts/patch-openclaw-shared-state-permissions.mts +++ b/scripts/patch-openclaw-shared-state-permissions.mts @@ -7,11 +7,11 @@ * * NemoClaw intentionally runs the OpenClaw CLI and gateway as separate users * in the same group. OpenClaw 2026.7.1 makes shared and per-agent SQLite state - * and private file stores part of gateway startup, but hardens those paths to - * owner-only modes. Preserve upstream behavior outside NemoClaw, use - * group-shared modes inside its image or an OpenShell sandbox, and ignore only - * the obsolete pinned-version update cache when its migration cannot archive - * through a shields-protected parent. + * part of gateway startup, but hardens those paths to owner-only modes. Use + * group-shared modes for those databases inside the NemoClaw image or an + * OpenShell sandbox, keep generic credential and identity stores owner-only, + * and ignore only the obsolete pinned-version update cache when its migration + * cannot archive through a shields-protected parent. * * Remove this patch once upstream supports a group-shared state database for * split-user containers without requiring a non-owner to chmod an already @@ -26,9 +26,7 @@ const SCRIPT_PATH = fileURLToPath(import.meta.url); export const MARKER = "/* nemoclaw: group-shared OpenClaw state */"; export const AGENT_MARKER = "/* nemoclaw: group-shared OpenClaw agent state */"; -export const SECRET_MARKER = "/* nemoclaw: group-shared OpenClaw private store */"; export const MIGRATION_MARKER = "/* nemoclaw: ignore legacy OpenClaw update-check state */"; -export const FILE_STORE_MARKER = "/* nemoclaw: group-shared OpenClaw file-store defaults */"; export const MODELS_MARKER = "/* nemoclaw: group-shared OpenClaw models file */"; const GROUP_SHARED_ENV_HELPER = [ @@ -184,65 +182,6 @@ const PATCHED_AGENT_REQUIRED_PATTERNS = [ "(statSync(candidate).mode & 0o7777) !== nemoclawAgentFileMode", ] as const; -const UPSTREAM_SECRET_MODE_CONSTANTS = [ - "const PRIVATE_SECRET_DIR_MODE = 448;", - "const PRIVATE_SECRET_FILE_MODE = 384;", -].join("\n"); - -const PATCHED_SECRET_MODE_CONSTANTS = [ - `const NEMOCLAW_SHARED_SECRET_DIR_MODE = 0o2770; ${SECRET_MARKER}`, - "const NEMOCLAW_SHARED_SECRET_FILE_MODE = 0o660;", - GROUP_SHARED_ENV_HELPER, - "const PRIVATE_SECRET_DIR_MODE = nemoclawUsesGroupSharedState() ? NEMOCLAW_SHARED_SECRET_DIR_MODE : 448;", - "const PRIVATE_SECRET_FILE_MODE = nemoclawUsesGroupSharedState() ? NEMOCLAW_SHARED_SECRET_FILE_MODE : 384;", -].join("\n"); - -const UPSTREAM_SECRET_PATH_MODE_HELPER = [ - "async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {", - '\tif (process.platform === "win32") return;', - "\tawait fs$1.chmod(resolvedPath, expectedMode);", - "\tconst actualMode = (await fs$1.stat(resolvedPath)).mode & 511;", - "\tif (actualMode !== expectedMode) throw new Error(`Private secret ${kind} ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);", - "}", -].join("\n"); - -const PATCHED_SECRET_PATH_MODE_HELPER = [ - "async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {", - '\tif (process.platform === "win32") return;', - "\tconst nemoclawGroupSharedState = nemoclawUsesGroupSharedState();", - "\tconst nemoclawModeMask = nemoclawGroupSharedState ? 0o7777 : 511;", - "\tif (nemoclawGroupSharedState && ((await fs$1.stat(resolvedPath)).mode & nemoclawModeMask) === expectedMode) return;", - "\tawait fs$1.chmod(resolvedPath, expectedMode);", - "\tconst actualMode = (await fs$1.stat(resolvedPath)).mode & nemoclawModeMask;", - "\tif (actualMode !== expectedMode) throw new Error(`Private secret ${kind} ${resolvedPath} has insecure permissions ${actualMode.toString(8)}.`);", - "}", -].join("\n"); - -const UPSTREAM_SECRET_WRITE_DEFAULTS = [ - "async function writeSecretFileAtomic(params) {", - "\tconst mode = params.mode ?? 384;", - "\tconst dirMode = params.dirMode ?? 448;", -].join("\n"); - -const PATCHED_SECRET_WRITE_DEFAULTS = [ - "async function writeSecretFileAtomic(params) {", - "\tconst mode = params.mode ?? PRIVATE_SECRET_FILE_MODE;", - "\tconst dirMode = params.dirMode ?? PRIVATE_SECRET_DIR_MODE;", -].join("\n"); - -const PATCHED_SECRET_REQUIRED_PATTERNS = [ - SECRET_MARKER, - "const NEMOCLAW_SHARED_SECRET_DIR_MODE = 0o2770;", - "const NEMOCLAW_SHARED_SECRET_FILE_MODE = 0o660;", - "const PRIVATE_SECRET_DIR_MODE = nemoclawUsesGroupSharedState()", - "const PRIVATE_SECRET_FILE_MODE = nemoclawUsesGroupSharedState()", - "const nemoclawModeMask = nemoclawGroupSharedState ? 0o7777 : 511;", - "nemoclawGroupSharedState && ((await fs$1.stat(resolvedPath)).mode & nemoclawModeMask) === expectedMode", - "const actualMode = (await fs$1.stat(resolvedPath)).mode & nemoclawModeMask;", - "const mode = params.mode ?? PRIVATE_SECRET_FILE_MODE;", - "const dirMode = params.dirMode ?? PRIVATE_SECRET_DIR_MODE;", -] as const; - const UPSTREAM_MIGRATION_FUNCTION_START = [ "function migrateLegacyUpdateCheckState(params) {", "\tconst changes = [];", @@ -270,51 +209,6 @@ const PATCHED_MIGRATION_REQUIRED_PATTERNS = [ "if (nemoclawUsesGroupSharedState()) return { changes, warnings };", ] as const; -const UPSTREAM_FILE_STORE_START = [ - "function fileStore(options) {", - "\tconst rootDir = path.resolve(options.rootDir);", - "\tconst privateMode = options.private ?? false;", - "\tconst dirMode = options.dirMode ?? 448;", - "\tconst mode = options.mode ?? 384;", -].join("\n"); - -const PATCHED_FILE_STORE_START = [ - GROUP_SHARED_ENV_HELPER, - `function fileStore(options) { ${FILE_STORE_MARKER}`, - "\tconst rootDir = path.resolve(options.rootDir);", - "\tconst privateMode = options.private ?? false;", - "\tconst nemoclawGroupSharedPrivateStore = privateMode && nemoclawUsesGroupSharedState();", - "\tconst dirMode = options.dirMode ?? (nemoclawGroupSharedPrivateStore ? 0o2770 : 448);", - "\tconst mode = options.mode ?? (nemoclawGroupSharedPrivateStore ? 0o660 : 384);", -].join("\n"); - -const UPSTREAM_FILE_STORE_SYNC_START = [ - "function fileStoreSync(options) {", - "\tconst rootDir = path.resolve(options.rootDir);", - "\tconst privateMode = options.private ?? false;", - "\tconst dirMode = options.dirMode ?? 448;", - "\tconst mode = options.mode ?? 384;", -].join("\n"); - -const PATCHED_FILE_STORE_SYNC_START = [ - "function fileStoreSync(options) {", - "\tconst rootDir = path.resolve(options.rootDir);", - "\tconst privateMode = options.private ?? false;", - "\tconst nemoclawGroupSharedPrivateStore = privateMode && nemoclawUsesGroupSharedState();", - "\tconst dirMode = options.dirMode ?? (nemoclawGroupSharedPrivateStore ? 0o2770 : 448);", - "\tconst mode = options.mode ?? (nemoclawGroupSharedPrivateStore ? 0o660 : 384);", -].join("\n"); - -const PATCHED_FILE_STORE_REQUIRED_PATTERNS = [ - FILE_STORE_MARKER, - "function nemoclawUsesGroupSharedState(env) {", - "function fileStore(options) {", - "function fileStoreSync(options) {", - "const nemoclawGroupSharedPrivateStore = privateMode && nemoclawUsesGroupSharedState();", - "const dirMode = options.dirMode ?? (nemoclawGroupSharedPrivateStore ? 0o2770 : 448);", - "const mode = options.mode ?? (nemoclawGroupSharedPrivateStore ? 0o660 : 384);", -] as const; - const UPSTREAM_MODELS_FILE_MODE_HELPER = [ "async function ensureModelsFileModeForModelsJson(pathname) {", "\tawait fs.chmod(pathname, 384).catch(() => {});", @@ -435,45 +329,6 @@ export function patchOpenClawAgentDbText(source: string, file: string): PatchTex return { patched: true, status: "patched", text }; } -function validatePatchedSecretText(source: string, file: string): void { - for (const pattern of PATCHED_SECRET_REQUIRED_PATTERNS) { - requireExactlyOnce(source, pattern, `patched pattern ${JSON.stringify(pattern)}`, file); - } - for (const upstreamTarget of [ - UPSTREAM_SECRET_MODE_CONSTANTS, - UPSTREAM_SECRET_PATH_MODE_HELPER, - UPSTREAM_SECRET_WRITE_DEFAULTS, - ]) { - if (source.includes(upstreamTarget)) { - throw new Error( - `${file}: patch marker is present but an upstream private-store target remains`, - ); - } - } -} - -export function patchOpenClawSecretFileText(source: string, file: string): PatchTextResult { - if (source.includes(SECRET_MARKER)) { - validatePatchedSecretText(source, file); - return { patched: false, status: "already-patched", text: source }; - } - - requireExactlyOnce(source, UPSTREAM_SECRET_MODE_CONSTANTS, "private-store mode constants", file); - requireExactlyOnce( - source, - UPSTREAM_SECRET_PATH_MODE_HELPER, - "private-store path mode helper", - file, - ); - requireExactlyOnce(source, UPSTREAM_SECRET_WRITE_DEFAULTS, "private-store write defaults", file); - const text = source - .replace(UPSTREAM_SECRET_MODE_CONSTANTS, PATCHED_SECRET_MODE_CONSTANTS) - .replace(UPSTREAM_SECRET_PATH_MODE_HELPER, PATCHED_SECRET_PATH_MODE_HELPER) - .replace(UPSTREAM_SECRET_WRITE_DEFAULTS, PATCHED_SECRET_WRITE_DEFAULTS); - validatePatchedSecretText(text, file); - return { patched: true, status: "patched", text }; -} - function validatePatchedMigrationText(source: string, file: string): void { for (const pattern of PATCHED_MIGRATION_REQUIRED_PATTERNS) { requireExactlyOnce(source, pattern, `patched pattern ${JSON.stringify(pattern)}`, file); @@ -495,39 +350,6 @@ export function patchOpenClawStateMigrationText(source: string, file: string): P return { patched: true, status: "patched", text }; } -function validatePatchedFileStoreText(source: string, file: string): void { - for (const pattern of PATCHED_FILE_STORE_REQUIRED_PATTERNS) { - const expectedCount = pattern.startsWith("const ") ? 2 : 1; - const count = countOccurrences(source, pattern); - if (count !== expectedCount) { - throw new Error( - `${file}: expected exactly ${expectedCount === 1 ? "one" : expectedCount} patched pattern ${JSON.stringify(pattern)}, found ${count}`, - ); - } - } - if ( - source.includes(UPSTREAM_FILE_STORE_START) || - source.includes(UPSTREAM_FILE_STORE_SYNC_START) - ) { - throw new Error(`${file}: patch marker is present but an upstream file-store target remains`); - } -} - -export function patchOpenClawFileStoreText(source: string, file: string): PatchTextResult { - if (source.includes(FILE_STORE_MARKER)) { - validatePatchedFileStoreText(source, file); - return { patched: false, status: "already-patched", text: source }; - } - - requireExactlyOnce(source, UPSTREAM_FILE_STORE_START, "async file-store defaults", file); - requireExactlyOnce(source, UPSTREAM_FILE_STORE_SYNC_START, "sync file-store defaults", file); - const text = source - .replace(UPSTREAM_FILE_STORE_START, PATCHED_FILE_STORE_START) - .replace(UPSTREAM_FILE_STORE_SYNC_START, PATCHED_FILE_STORE_SYNC_START); - validatePatchedFileStoreText(text, file); - return { patched: true, status: "patched", text }; -} - function validatePatchedModelsText(source: string, file: string): void { for (const pattern of PATCHED_MODELS_REQUIRED_PATTERNS) { requireExactlyOnce(source, pattern, `patched pattern ${JSON.stringify(pattern)}`, file); @@ -598,19 +420,6 @@ export function patchOpenClawSharedStatePermissions(distDir: string): PatchDistR `Expected exactly one OpenClaw per-agent database target in ${resolvedDist}, found ${agentCandidates.length}`, ); } - const secretCandidates = listCandidates(resolvedDist, /^secret-file-.+\.js$/).filter((file) => { - const source = fs.readFileSync(file, "utf8"); - return ( - source.includes(SECRET_MARKER) || - source.includes("const PRIVATE_SECRET_DIR_MODE = 448;") || - source.includes("async function enforcePrivatePathMode(resolvedPath, expectedMode, kind) {") - ); - }); - if (secretCandidates.length !== 1) { - throw new Error( - `Expected exactly one OpenClaw private-store target in ${resolvedDist}, found ${secretCandidates.length}`, - ); - } const migrationCandidates = listCandidates(resolvedDist, /^state-migrations-.+\.js$/).filter( (file) => { const source = fs.readFileSync(file, "utf8"); @@ -625,19 +434,6 @@ export function patchOpenClawSharedStatePermissions(distDir: string): PatchDistR `Expected exactly one OpenClaw state-migration target in ${resolvedDist}, found ${migrationCandidates.length}`, ); } - const fileStoreCandidates = listCandidates(resolvedDist, /^file-store-.+\.js$/).filter((file) => { - const source = fs.readFileSync(file, "utf8"); - return ( - source.includes(FILE_STORE_MARKER) || - (source.includes("function fileStore(options) {") && - source.includes("function fileStoreSync(options) {")) - ); - }); - if (fileStoreCandidates.length !== 1) { - throw new Error( - `Expected exactly one OpenClaw file-store target in ${resolvedDist}, found ${fileStoreCandidates.length}`, - ); - } const modelsCandidates = listCandidates(resolvedDist, /^models-config-.+\.js$/).filter((file) => { const source = fs.readFileSync(file, "utf8"); return ( @@ -656,40 +452,26 @@ export function patchOpenClawSharedStatePermissions(distDir: string): PatchDistR const stateFile = stateCandidates[0]; const agentFile = agentCandidates[0]; - const secretFile = secretCandidates[0]; const migrationFile = migrationCandidates[0]; - const fileStoreFile = fileStoreCandidates[0]; const modelsFile = modelsCandidates[0]; const stateResult = patchOpenClawStateDbText(fs.readFileSync(stateFile, "utf8"), stateFile); const agentResult = patchOpenClawAgentDbText(fs.readFileSync(agentFile, "utf8"), agentFile); - const secretResult = patchOpenClawSecretFileText(fs.readFileSync(secretFile, "utf8"), secretFile); const migrationResult = patchOpenClawStateMigrationText( fs.readFileSync(migrationFile, "utf8"), migrationFile, ); - const fileStoreResult = patchOpenClawFileStoreText( - fs.readFileSync(fileStoreFile, "utf8"), - fileStoreFile, - ); const modelsResult = patchOpenClawModelsConfigText( fs.readFileSync(modelsFile, "utf8"), modelsFile, ); if (stateResult.patched) fs.writeFileSync(stateFile, stateResult.text); if (agentResult.patched) fs.writeFileSync(agentFile, agentResult.text); - if (secretResult.patched) fs.writeFileSync(secretFile, secretResult.text); if (migrationResult.patched) fs.writeFileSync(migrationFile, migrationResult.text); - if (fileStoreResult.patched) fs.writeFileSync(fileStoreFile, fileStoreResult.text); if (modelsResult.patched) fs.writeFileSync(modelsFile, modelsResult.text); const patched = - stateResult.patched || - agentResult.patched || - secretResult.patched || - migrationResult.patched || - fileStoreResult.patched || - modelsResult.patched; + stateResult.patched || agentResult.patched || migrationResult.patched || modelsResult.patched; return { - files: [stateFile, agentFile, secretFile, migrationFile, fileStoreFile, modelsFile], + files: [stateFile, agentFile, migrationFile, modelsFile], patched, status: patched ? "patched" : "already-patched", }; diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index 4d02e90ee42..b462887f713 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -48,6 +48,7 @@ const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "42ef0b12e92ebe146c25367831b4ce3a2664f0fa99fd5e4fb98a8939d3af8800", "8b49e78185185f1b7e24d01631186554fef21d2300db65c9bc9998e7ec00469f", "a0a554d474cb70087e50686d998915eae06201d6182a2410d3ccc4879e5058e6", + "5af905889f94ffed2f6c371111d0589e38eed7b0de54ddb0dd68ad912a23149a", ]); function instructionSha256(text: string): string { diff --git a/test/helpers/openclaw-device-self-approval-patch-harness.ts b/test/helpers/openclaw-device-self-approval-patch-harness.ts index a962783f697..56ad5f238d7 100644 --- a/test/helpers/openclaw-device-self-approval-patch-harness.ts +++ b/test/helpers/openclaw-device-self-approval-patch-harness.ts @@ -15,6 +15,28 @@ function compiledIndent(source: string): string { return source.replace(/^( +)/gmu, (indent) => "\t".repeat(Math.floor(indent.length / 2))); } +function gatewayCallFixture(): string { + return compiledIndent(` +const process = { env: {} }; +const GATEWAY_CLIENT_NAMES = { CLI: "cli", GATEWAY_CLIENT: "gateway-client" }; +const GATEWAY_CLIENT_MODES = { CLI: "cli", BACKEND: "backend" }; +function isLoopbackGatewayUrl(url) { return url.startsWith("ws://127.0.0.1:"); } +function shouldOmitDeviceIdentityForGatewayCall(params) { + const mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI; + const clientName = params.opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI; + const hasSharedSecretAuth = params.authMode === "token" && Boolean(params.token) || params.authMode === "password" && Boolean(params.password); + const isLoopback = isLoopbackGatewayUrl(params.url); + const isLocalBackendSharedAuth = mode === GATEWAY_CLIENT_MODES.BACKEND && clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT && (hasSharedSecretAuth || params.allowAuthNone === true) && isLoopback; + const isLocalCliSharedAuth = mode === GATEWAY_CLIENT_MODES.CLI && clientName === GATEWAY_CLIENT_NAMES.CLI && hasSharedSecretAuth && isLoopback; + return isLocalBackendSharedAuth || isLocalCliSharedAuth; +} +function setForceDevicePairing(value) { + if (value) process.env.NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING = "1"; + else delete process.env.NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING; +} +`); +} + function cliFixture(): string { return compiledIndent(` const ADMIN_SCOPE = "operator.admin"; @@ -491,6 +513,7 @@ async function approveBootstrapDevicePairing(requestId, bootstrapProfile, option } export function writeFixtureDist(dist: string): void { + fs.writeFileSync(path.join(dist, "call-fixture.js"), gatewayCallFixture()); fs.writeFileSync(path.join(dist, "devices-cli.runtime-fixture.js"), cliFixture()); fs.writeFileSync(path.join(dist, "message-handler-fixture.js"), gatewayAuthFixture()); fs.writeFileSync(path.join(dist, "devices-fixture.js"), handlerFixture()); diff --git a/test/helpers/openclaw-real-device-self-approval-proof.ts b/test/helpers/openclaw-real-device-self-approval-proof.ts index 57e1cb2352b..b625d42885f 100644 --- a/test/helpers/openclaw-real-device-self-approval-proof.ts +++ b/test/helpers/openclaw-real-device-self-approval-proof.ts @@ -148,6 +148,16 @@ function requireRealStoredDeviceAuthLinkage(sources: DistSource[], cliSource: Di "opts.requiredStoredDeviceAuthScopes", "scopes: useStoredDeviceAuth ? void 0 : scopes", ]); + requireOrderedMarkers( + gatewayCall.source, + [ + "function shouldOmitDeviceIdentityForGatewayCall(params)", + "NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING", + "nemoclaw: force device identity for loopback pairing bootstrap", + "const mode = params.opts.mode", + ], + "loopback pairing-bootstrap device identity", + ); requireOrderedMarkers( gatewayCall.source, [ @@ -1152,17 +1162,19 @@ export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptio ); requireSuccess(audit, "audit bounded device self-approval patch"); for (const marker of [ + "gateway call device-identity runtime:", "devices CLI approval runtime:", "device-token scope-upgrade gateway auth runtime:", "device pairing gateway handler:", "canonical device pairing state runtime:", - "Summary: 4 OK · 0 missing", + "Summary: 5 OK · 0 missing", ]) { requireIncludes(audit.stdout, marker, "device self-approval audit"); } const sources = readDistSources(options.dist); for (const marker of [ + "nemoclaw: force device identity for loopback pairing bootstrap", "nemoclaw: reach gateway for bounded same-device scope approval", "nemoclaw: route bounded CLI device-token scope upgrade into pairing", "nemoclaw: bounded same-device scope approval", @@ -1208,7 +1220,7 @@ export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptio const packageDir = path.dirname(options.dist); const install = spawnSync( "npm", - ["install", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], + ["install", "--ignore-scripts", "--omit=dev", "--legacy-peer-deps", "--no-audit", "--no-fund"], { cwd: packageDir, encoding: "utf8", timeout: 120_000 }, ); requireSuccess(install, "install reviewed OpenClaw runtime dependencies without scripts"); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index d46c496ae66..d2e8d318498 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -1542,7 +1542,6 @@ describe("nemoclaw-start auto-pair client whitelisting (#117)", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); - it("approves only known client identities and does not reprocess handled requests", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-")); const fakeOpenclaw = path.join(tmpDir, "openclaw"); @@ -1569,7 +1568,7 @@ describe("nemoclaw-start auto-pair client whitelisting (#117)", () => { `#!/usr/bin/env bash set -euo pipefail if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "list" ]; then - printf 'list:%s:%s:%s\n' "\${OPENCLAW_GATEWAY_URL-unset}" "\${OPENCLAW_GATEWAY_PORT-unset}" "\${OPENCLAW_GATEWAY_TOKEN-unset}" >> ${JSON.stringify(envLog)} + printf 'list:%s:%s:%s:%s\n' "\${OPENCLAW_GATEWAY_URL-unset}" "\${OPENCLAW_GATEWAY_PORT-unset}" "\${OPENCLAW_GATEWAY_TOKEN-unset}" "\${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING-unset}" >> ${JSON.stringify(envLog)} count="$(cat ${JSON.stringify(stateFile)} 2>/dev/null || echo 0)" count=$((count + 1)) echo "$count" > ${JSON.stringify(stateFile)} @@ -1624,7 +1623,8 @@ exit 2 "ok-agent-cli", ]); const envLogLines = fs.readFileSync(envLog, "utf-8").trim().split("\n"); - expect(envLogLines).toContain("list:unset:unset:unset"); + expect(envLogLines[0]).toBe("list:ws://127.0.0.1:18789:18789:test-gateway-token:1"); + expect(envLogLines).toContain("list:unset:unset:unset:unset"); expect(envLogLines).toContain("approve:ok-browser:unset:unset:unset"); expect(envLogLines).toContain("approve:ok-agent-cli:unset:unset:unset"); expect(envLogLines).not.toContain("approve:ok-webchat:unset:unset:unset"); diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index fb9ff192fd4..4de5b11ac95 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -430,8 +430,9 @@ check_not_contains "$optional_plugin_block" 'pack_reviewed_npm_tarball' "optiona shared_state_permissions_patch=${JSON.stringify(SHARED_STATE_PERMISSIONS_PATCH)} grep -Fq 'nemoclaw: group-shared OpenClaw state' "$shared_state_permissions_patch" grep -Fq 'nemoclaw: group-shared OpenClaw agent state' "$shared_state_permissions_patch" - grep -Fq 'nemoclaw: group-shared OpenClaw private store' "$shared_state_permissions_patch" - grep -Fq 'nemoclaw: group-shared OpenClaw file-store defaults' "$shared_state_permissions_patch" + grep -Fq 'keep generic credential and identity stores owner-only' "$shared_state_permissions_patch" + ! grep -Fq 'nemoclaw: group-shared OpenClaw private store' "$shared_state_permissions_patch" + ! grep -Fq 'nemoclaw: group-shared OpenClaw file-store defaults' "$shared_state_permissions_patch" grep -Fq 'nemoclaw: group-shared OpenClaw models file' "$shared_state_permissions_patch" grep -Fq 'nemoclaw: ignore legacy OpenClaw update-check state' "$shared_state_permissions_patch" grep -Fq 'COPY scripts/patch-openclaw-shared-state-permissions.mts /usr/local/lib/nemoclaw/patch-openclaw-shared-state-permissions.mts' Dockerfile diff --git a/test/openclaw-device-self-approval-patch.test.ts b/test/openclaw-device-self-approval-patch.test.ts index 5a319567546..cec1e78f55a 100644 --- a/test/openclaw-device-self-approval-patch.test.ts +++ b/test/openclaw-device-self-approval-patch.test.ts @@ -150,7 +150,7 @@ function selfApprovalOptions() { } describe("OpenClaw bounded device self-approval patch (#4462)", () => { - it("applies and audits exactly one CLI, gateway-auth, gateway-handler, and canonical-state target", () => { + it("applies and audits each reviewed CLI, gateway, and canonical-state target", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-self-approval-")); const dist = path.join(tmp, "dist"); fs.mkdirSync(dist); @@ -158,14 +158,14 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { try { const freshAudit = runPatch(dist, true); expect(freshAudit.status, `${freshAudit.stdout}${freshAudit.stderr}`).toBe(0); - expect(freshAudit.stdout).toContain("4 OK · 0 missing"); + expect(freshAudit.stdout).toContain("5 OK · 0 missing"); expect(freshAudit.stdout).toContain("would-apply"); const apply = runPatch(dist); expect(apply.status, `${apply.stdout}${apply.stderr}`).toBe(0); const appliedAudit = runPatch(dist, true); expect(appliedAudit.status, `${appliedAudit.stdout}${appliedAudit.stderr}`).toBe(0); - expect(appliedAudit.stdout.match(/already-applied/gu)).toHaveLength(4); + expect(appliedAudit.stdout.match(/already-applied/gu)).toHaveLength(5); const secondApply = runPatch(dist); expect(secondApply.status, `${secondApply.stdout}${secondApply.stderr}`).toBe(0); @@ -174,6 +174,35 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { } }); + it("retains device identity only for the explicitly marked loopback bootstrap child", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-identity-bootstrap-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist); + writeFixtureDist(dist); + try { + expect(runPatch(dist).status).toBe(0); + const source = fs.readFileSync(path.join(dist, "call-fixture.js"), "utf8"); + const runtime = runFixture<{ + setForceDevicePairing(value: boolean): void; + shouldOmitDeviceIdentityForGatewayCall(params: Record): boolean; + }>(source, "({ setForceDevicePairing, shouldOmitDeviceIdentityForGatewayCall })"); + const params = { + authMode: "token", + opts: { clientName: "cli", mode: "cli" }, + token: "loopback-token", + url: "ws://127.0.0.1:18789", + }; + + expect(runtime.shouldOmitDeviceIdentityForGatewayCall(params)).toBe(true); + runtime.setForceDevicePairing(true); + expect(runtime.shouldOmitDeviceIdentityForGatewayCall(params)).toBe(false); + runtime.setForceDevicePairing(false); + expect(runtime.shouldOmitDeviceIdentityForGatewayCall(params)).toBe(true); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("routes only a bounded CLI device-token scope mismatch into canonical pairing", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-auth-upgrade-")); const dist = path.join(tmp, "dist"); diff --git a/test/openclaw-real-patched-dist-harness.test.ts b/test/openclaw-real-patched-dist-harness.test.ts index e783cea27c1..399840627c4 100644 --- a/test/openclaw-real-patched-dist-harness.test.ts +++ b/test/openclaw-real-patched-dist-harness.test.ts @@ -586,15 +586,28 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( .readdirSync(dist) .filter((file) => /^secret-file-.+\.js$/.test(file)) .map((file) => path.join(dist, file)) - .filter((file) => - fs - .readFileSync(file, "utf-8") - .includes("/* nemoclaw: group-shared OpenClaw private store */"), - ); + .filter((file) => { + const source = fs.readFileSync(file, "utf-8"); + return ( + source.includes("const PRIVATE_SECRET_DIR_MODE = 448;") && + source.includes("const PRIVATE_SECRET_FILE_MODE = 384;") + ); + }); requireRuntimeEqual( String(privateStoreTargets.length), "1", - "private-store patch target count", + "owner-only private-store target count", + ); + requireRuntimeEqual( + String( + privateStoreTargets.some((file) => + fs + .readFileSync(file, "utf-8") + .includes("/* nemoclaw: group-shared OpenClaw private store */"), + ), + ), + "false", + "generic private-store sharing marker", ); const stateMigrationTargets = fs .readdirSync(dist) @@ -614,15 +627,30 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( .readdirSync(dist) .filter((file) => /^file-store-.+\.js$/.test(file)) .map((file) => path.join(dist, file)) - .filter((file) => - fs - .readFileSync(file, "utf-8") - .includes("/* nemoclaw: group-shared OpenClaw file-store defaults */"), - ); + .filter((file) => { + const source = fs.readFileSync(file, "utf-8"); + return ( + source.includes("function fileStore(options) {") && + source.includes("function fileStoreSync(options) {") && + source.includes("const dirMode = options.dirMode ?? 448;") && + source.includes("const mode = options.mode ?? 384;") + ); + }); requireRuntimeEqual( String(fileStoreTargets.length), "1", - "private file-store defaults patch target count", + "owner-only file-store defaults target count", + ); + requireRuntimeEqual( + String( + fileStoreTargets.some((file) => + fs + .readFileSync(file, "utf-8") + .includes("/* nemoclaw: group-shared OpenClaw file-store defaults */"), + ), + ), + "false", + "generic file-store sharing marker", ); const modelsConfigTargets = fs .readdirSync(dist) @@ -650,7 +678,7 @@ describe.skipIf(process.env.NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS !== "1")( encoding: "utf-8", timeout: PATCH_COMMAND_TIMEOUT_MS, }); - requireSpawnSuccess(syntax, `validate patched SQLite state syntax: ${target}`); + requireSpawnSuccess(syntax, `validate reviewed OpenClaw dist syntax: ${target}`); } // This proof installs the reviewed shrinkwrapped runtime dependencies diff --git a/test/openclaw-shared-state-permissions-patch.test.ts b/test/openclaw-shared-state-permissions-patch.test.ts index 7e6d1378ec2..a0fb79f0b8f 100644 --- a/test/openclaw-shared-state-permissions-patch.test.ts +++ b/test/openclaw-shared-state-permissions-patch.test.ts @@ -10,18 +10,14 @@ import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { AGENT_MARKER, - FILE_STORE_MARKER, MARKER, MIGRATION_MARKER, MODELS_MARKER, patchOpenClawAgentDbText, - patchOpenClawFileStoreText, patchOpenClawModelsConfigText, - patchOpenClawSecretFileText, patchOpenClawSharedStatePermissions, patchOpenClawStateDbText, patchOpenClawStateMigrationText, - SECRET_MARKER, } from "../scripts/patch-openclaw-shared-state-permissions.mts"; import { restoreEnv } from "./helpers/env-test-helpers"; @@ -380,28 +376,27 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { expect(first.stdout).toContain("SQLite state permissions patched"); const patchedState = fs.readFileSync(fixture.stateFiles[0], "utf8"); const patchedAgent = fs.readFileSync(fixture.agentFiles[0], "utf8"); - const patchedSecret = fs.readFileSync(fixture.secretFiles[0], "utf8"); + const upstreamSecret = fs.readFileSync(fixture.secretFiles[0], "utf8"); const patchedMigration = fs.readFileSync(fixture.migrationFiles[0], "utf8"); - const patchedFileStore = fs.readFileSync(fixture.fileStoreFiles[0], "utf8"); + const upstreamFileStore = fs.readFileSync(fixture.fileStoreFiles[0], "utf8"); const patchedModels = fs.readFileSync(fixture.modelsFiles[0], "utf8"); expect(patchedState.split(MARKER)).toHaveLength(2); expect(patchedAgent.split(AGENT_MARKER)).toHaveLength(2); - expect(patchedSecret.split(SECRET_MARKER)).toHaveLength(2); expect(patchedMigration.split(MIGRATION_MARKER)).toHaveLength(2); - expect(patchedFileStore.split(FILE_STORE_MARKER)).toHaveLength(2); expect(patchedModels.split(MODELS_MARKER)).toHaveLength(2); expect(patchedState).toContain("NEMOCLAW_SHARED_STATE_DIR_MODE = 0o2770"); expect(patchedAgent).toContain("NEMOCLAW_SHARED_AGENT_DB_DIR_MODE = 0o2770"); - expect(patchedSecret).toContain("NEMOCLAW_SHARED_SECRET_DIR_MODE = 0o2770"); + expect(upstreamSecret).toBe(UPSTREAM_SECRET_FILE_SOURCE); + expect(upstreamFileStore).toBe(UPSTREAM_FILE_STORE_SOURCE); const second = runPatch(fixture.dist); expect(second.status, `${second.stdout}${second.stderr}`).toBe(0); expect(second.stdout).toContain("SQLite state permissions already-patched"); expect(fs.readFileSync(fixture.stateFiles[0], "utf8")).toBe(patchedState); expect(fs.readFileSync(fixture.agentFiles[0], "utf8")).toBe(patchedAgent); - expect(fs.readFileSync(fixture.secretFiles[0], "utf8")).toBe(patchedSecret); + expect(fs.readFileSync(fixture.secretFiles[0], "utf8")).toBe(upstreamSecret); expect(fs.readFileSync(fixture.migrationFiles[0], "utf8")).toBe(patchedMigration); - expect(fs.readFileSync(fixture.fileStoreFiles[0], "utf8")).toBe(patchedFileStore); + expect(fs.readFileSync(fixture.fileStoreFiles[0], "utf8")).toBe(upstreamFileStore); expect(fs.readFileSync(fixture.modelsFiles[0], "utf8")).toBe(patchedModels); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); @@ -507,7 +502,7 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { } }); - it("keeps private-store paths group-accessible without rechmodding inherited directories", async () => { + it("retains owner-only secret-file modes under the NemoClaw marker", async () => { const fixture = makeFixture(); const previousMarker = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; try { @@ -519,9 +514,9 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { fs.chmodSync(privateDir, 0o2770); const defaults = await runtime.writeSecretFileAtomic({}); - expect(defaults).toEqual({ dirMode: 0o2770, mode: 0o660 }); + expect(defaults).toEqual({ dirMode: 0o700, mode: 0o600 }); await runtime.enforcePrivatePathMode(privateDir, defaults.dirMode, "directory"); - expect(mode(privateDir)).toBe(0o2770); + expect(mode(privateDir)).toBe(0o700); } finally { restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousMarker); fs.rmSync(fixture.root, { recursive: true, force: true }); @@ -529,53 +524,57 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { }); it.skipIf(process.platform !== "linux" || (process.getuid?.() ?? -1) !== 0)( - "preserves the shared group on files created by a different private-store user", + "denies a same-group user access to another user's device identity", async () => { const fixture = makeFixture(); - const previousMarker = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; try { patchOpenClawSharedStatePermissions(fixture.dist); - process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = "1"; const runtime = await importFileStoreFixture(fixture.fileStoreFiles[0]); - const sharedDir = path.join(fixture.root, "cross-user-private-store"); - const sharedFile = path.join(sharedDir, "identity.json"); - const defaults = runtime.fileStore({ rootDir: sharedDir, private: true }); + const privateDir = path.join(fixture.root, "gateway-private-store"); + const identityFile = path.join(privateDir, "identity.json"); + const defaults = runtime.fileStore({ rootDir: privateDir, private: true }); const sharedGid = 65_534; + const gatewayUid = 65_532; + const sandboxUid = 65_533; fs.chmodSync(fixture.root, 0o755); - fs.mkdirSync(sharedDir, { mode: 0o700 }); - fs.chownSync(sharedDir, 0, sharedGid); - fs.chmodSync(sharedDir, defaults.dirMode); + fs.mkdirSync(privateDir, { mode: defaults.dirMode }); + fs.chownSync(privateDir, gatewayUid, sharedGid); + fs.chmodSync(privateDir, defaults.dirMode); const writer = spawnSync( process.execPath, [ "-e", - `process.umask(0); require("node:fs").writeFileSync(${JSON.stringify(sharedFile)}, "shared", { mode: ${defaults.mode} });`, + `process.umask(0); require("node:fs").writeFileSync(${JSON.stringify(identityFile)}, "gateway-credential", { mode: ${defaults.mode} });`, ], - { encoding: "utf8", gid: sharedGid, uid: 65_532 }, + { encoding: "utf8", gid: sharedGid, uid: gatewayUid }, ); expect(writer.status, writer.stderr).toBe(0); - expect(fs.statSync(sharedFile).gid).toBe(sharedGid); - expect(mode(sharedFile)).toBe(0o660); + expect(mode(privateDir)).toBe(0o700); + expect(mode(identityFile)).toBe(0o600); const reader = spawnSync( process.execPath, - [ - "-e", - `process.stdout.write(require("node:fs").readFileSync(${JSON.stringify(sharedFile)}, "utf8"))`, - ], - { encoding: "utf8", gid: sharedGid, uid: 65_533 }, + ["-e", `require("node:fs").readFileSync(${JSON.stringify(identityFile)}, "utf8")`], + { encoding: "utf8", gid: sharedGid, uid: sandboxUid }, ); - expect(reader.status, reader.stderr).toBe(0); - expect(reader.stdout).toBe("shared"); + expect(reader.status).not.toBe(0); + expect(reader.stderr).toContain("EACCES"); + + const writerFromSandbox = spawnSync( + process.execPath, + ["-e", `require("node:fs").appendFileSync(${JSON.stringify(identityFile)}, "tampered")`], + { encoding: "utf8", gid: sharedGid, uid: sandboxUid }, + ); + expect(writerFromSandbox.status).not.toBe(0); + expect(writerFromSandbox.stderr).toContain("EACCES"); } finally { - restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousMarker); fs.rmSync(fixture.root, { recursive: true, force: true }); } }, ); - it("selects group-shared defaults for normal async and sync private stores only", async () => { + it("retains owner-only defaults for async and sync private stores under NemoClaw", async () => { const fixture = makeFixture(); const previousMarker = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; try { @@ -588,7 +587,7 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { runtime.fileStore({ rootDir, private: true }), runtime.fileStoreSync({ rootDir, private: true }), ]) { - expect(result).toMatchObject({ dirMode: 0o2770, mode: 0o660, privateMode: true }); + expect(result).toMatchObject({ dirMode: 0o700, mode: 0o600, privateMode: true }); } expect(runtime.fileStore({ rootDir })).toMatchObject({ dirMode: 0o700, @@ -889,34 +888,6 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { fs.rmSync(ambiguousMigration.root, { recursive: true, force: true }); } - const missingFileStore = makeFixture(1, 1, 1, 1, 0); - try { - expect(() => patchOpenClawSharedStatePermissions(missingFileStore.dist)).toThrow( - "Expected exactly one OpenClaw file-store target", - ); - } finally { - fs.rmSync(missingFileStore.root, { recursive: true, force: true }); - } - - const ambiguousFileStore = makeFixture(1, 1, 1, 1, 2); - try { - expect(() => patchOpenClawSharedStatePermissions(ambiguousFileStore.dist)).toThrow( - "Expected exactly one OpenClaw file-store target", - ); - for (const file of [ - ...ambiguousFileStore.stateFiles, - ...ambiguousFileStore.agentFiles, - ...ambiguousFileStore.secretFiles, - ...ambiguousFileStore.migrationFiles, - ...ambiguousFileStore.fileStoreFiles, - ...ambiguousFileStore.modelsFiles, - ]) { - expect(fs.readFileSync(file, "utf8")).not.toContain("nemoclaw:"); - } - } finally { - fs.rmSync(ambiguousFileStore.root, { recursive: true, force: true }); - } - const missingModels = makeFixture(1, 1, 1, 1, 1, 0); try { expect(() => patchOpenClawSharedStatePermissions(missingModels.dist)).toThrow( @@ -951,24 +922,12 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { expect(() => patchOpenClawAgentDbText(`${UPSTREAM_AGENT_DB_SOURCE}\n${AGENT_MARKER}\n`, "partial.js"), ).toThrow("expected exactly one patched pattern"); - expect(() => - patchOpenClawSecretFileText( - `${UPSTREAM_SECRET_FILE_SOURCE}\n${SECRET_MARKER}\n`, - "partial.js", - ), - ).toThrow("expected exactly one patched pattern"); expect(() => patchOpenClawStateMigrationText( `${UPSTREAM_STATE_MIGRATION_SOURCE}\n${MIGRATION_MARKER}\n`, "partial.js", ), ).toThrow("expected exactly one patched pattern"); - expect(() => - patchOpenClawFileStoreText( - `${UPSTREAM_FILE_STORE_SOURCE}\n${FILE_STORE_MARKER}\n`, - "partial.js", - ), - ).toThrow("expected exactly one patched pattern"); expect(() => patchOpenClawModelsConfigText( `${UPSTREAM_MODELS_CONFIG_SOURCE}\n${MODELS_MARKER}\n`, From f4b7f288702f7e62842827d65b3d8ddc8749733c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 08:01:03 -0700 Subject: [PATCH 11/24] fix(openclaw): preserve state across 2026.7.1 upgrade Signed-off-by: Aaron Erickson --- .github/workflows/e2e.yaml | 19 +- Dockerfile | 22 +- .../openclaw-2026.7.1-dependency-review.md | 52 ++-- scripts/lib/openclaw-npm-remediation.mts | 35 +-- scripts/nemoclaw-start.sh | 18 ++ ...atch-openclaw-shared-state-permissions.mts | 31 +- .../live/openshell-gateway-upgrade-helpers.ts | 2 + .../live/openshell-gateway-upgrade.test.ts | 280 +++++++++++++++++- ...-gateway-upgrade-workflow-boundary.test.ts | 15 +- ...moclaw-start-shared-state-topology.test.ts | 67 +++++ test/openclaw-npm-remediation.test.ts | 47 ++- ...law-shared-state-permissions-patch.test.ts | 47 ++- test/sandbox-provisioning.test.ts | 4 +- ...hell-gateway-upgrade-workflow-boundary.mts | 34 ++- 14 files changed, 586 insertions(+), 87 deletions(-) create mode 100644 test/nemoclaw-start-shared-state-topology.test.ts diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index ccd3e548db6..7af861b0c32 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -3436,7 +3436,7 @@ jobs: needs: generate-matrix if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',openshell-gateway-upgrade,') || contains(format(',{0},', inputs.targets), ',openshell-gateway-upgrade,') }} runs-on: ${{ matrix.runner }} - timeout-minutes: 60 + timeout-minutes: 70 strategy: fail-fast: false matrix: @@ -3477,6 +3477,17 @@ jobs: sandbox_base_image_ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:104151ffadc2ff0b6c815e3c95c2783ced61aee0d0f83fc327cc02be9b7e14e6" openshell_version: 0.0.72 openclaw_version: 2026.5.27 + - id: v0.0.89-x86_64 + runner: ubuntu-latest + shard: v0-0-89-x86-64 + nemoclaw_ref: v0.0.89 + nemoclaw_commit: "1143aa5cce77f3bad1b3b5588bd7fddbe438237e" + installer_sha256: "00f24959e5ca68104fe91221c0a015dab6a4154618497fa36b969b661f418cc2" + sandbox_base_image_ref: "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:3265d482f67c9d81ee3a59b0bbad5eb5ea6c705fea81ece8ae888ed12794f7f1" + openshell_version: 0.0.85 + openclaw_version: 2026.6.10 + current_openclaw_version: 2026.7.1 + openclaw_state_upgrade: "1" env: E2E_JOB: "1" E2E_TARGET_ID: "openshell-gateway-upgrade" @@ -3493,6 +3504,8 @@ jobs: NEMOCLAW_OLD_SANDBOX_BASE_IMAGE_REF: ${{ matrix.sandbox_base_image_ref }} NEMOCLAW_OLD_OPENSHELL_VERSION: ${{ matrix.openshell_version }} NEMOCLAW_OLD_OPENCLAW_VERSION: ${{ matrix.openclaw_version }} + NEMOCLAW_CURRENT_OPENCLAW_VERSION: ${{ matrix.current_openclaw_version }} + NEMOCLAW_OPENCLAW_STATE_UPGRADE_PROOF: ${{ matrix.openclaw_state_upgrade }} OPENSHELL_GATEWAY: "nemoclaw" steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -3508,7 +3521,9 @@ jobs: - name: Run OpenShell gateway upgrade live Vitest test # Keep the original v0.0.36 fixture on x86_64 and validate the exact # v0.0.55/OpenShell 0.0.44 regression shape on x86_64 and arm64, plus - # the immediate v0.0.74/OpenShell 0.0.72 predecessor to this bump. + # the immediate v0.0.74/OpenShell 0.0.72 predecessor to this bump. The + # v0.0.89 row proves OpenClaw 2026.6.10 state survives the 2026.7.1 + # migration without crossing the OpenShell secret boundary. env: GITHUB_TOKEN: ${{ github.token }} run: | diff --git a/Dockerfile b/Dockerfile index ddd2744c729..c23f1e69c32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -85,11 +85,6 @@ ENV AWS_EC2_METADATA_DISABLED=true # names do not persist under /tmp/jiti inside the sandbox. ENV JITI_FS_CACHE=false -# NemoClaw always runs OpenClaw CLI work and the gateway as separate users in -# one shared group, including direct Docker entrypoint deployments that do not -# receive an OpenShell sandbox marker. -ENV NEMOCLAW_OPENCLAW_SHARED_STATE=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 # here; the RUN below decodes it to a root-owned file that the entrypoint @@ -792,14 +787,15 @@ RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-tool- /usr/local/lib/node_modules/openclaw/dist # OpenClaw 2026.7.1 moved gateway startup work into shared and per-agent SQLite -# databases, but hardens them to owner-only modes on every open. NemoClaw runs -# the CLI and gateway as separate users in the sandbox group, so use -# group-shared modes inside the NemoClaw image or an OpenShell sandbox. The -# patch keeps generic credential and identity stores owner-only, avoids a -# non-owner chmod when a reviewed shared database mode is already safe, keeps -# generated models files readable by the shared group, and ignores the obsolete -# update-check cache migration that cannot archive across a shields-protected -# parent. +# databases, but hardens them to owner-only modes on every open. NemoClaw's +# root entrypoint runs the CLI and gateway as separate users in the sandbox +# group, so use group-shared modes only when that split-user runtime marker is +# present. Same-UID OpenShell sandboxes retain OpenClaw's private modes. The +# patch leaves generic credential and identity store enforcement unchanged, +# avoids a non-owner chmod when a reviewed shared database mode is already +# safe, keeps generated models files readable by the shared group, and ignores +# the obsolete update-check cache migration that cannot archive across a +# shields-protected parent. # # Removal criteria: drop when upstream OpenClaw supports a split-user, # group-shared state databases and split-user cache migrations without diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 07c1f65c721..00fc52f7b37 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -121,30 +121,31 @@ The `2026.7.1` dist changed seven reviewed shapes: - shared and per-agent SQLite state now run during the required gateway startup checkpoint and apply owner-only modes on each open. `scripts/patch-openclaw-shared-state-permissions.mts` keeps the upstream - `0700` directory and `0600` file modes when the OpenShell sandbox marker is - absent. - The image-wide `NEMOCLAW_OPENCLAW_SHARED_STATE=1` marker enables `2770` and - `0660` for direct containers. The legacy `OPENSHELL_SANDBOX=1` marker and - current validated OpenShell sandbox names cover runtimes that sanitize image - environment variables. The separate `sandbox` and `gateway` users can then - access the same database through their shared group. Workers that receive a - narrowed environment inherit these markers from the gateway process. + `0700` directory and `0600` file modes in same-UID OpenShell sandboxes. + The entrypoint derives `NEMOCLAW_OPENCLAW_SHARED_STATE=1` only for its root, + split-user topology; it explicitly removes that marker from non-root + OpenShell startup. Direct-container `sandbox` and `gateway` users can then + access the same database through their shared group, while OpenShell keeps + OpenClaw's native private-mode contract. The image does not retain this + marker; its descriptor-safe final repair normalizes any build-created global + SQLite files for a later direct runtime. Gateway workers and direct-container + connect shells inherit the entrypoint-derived marker. It skips `chmod` only when the existing mode already matches and rejects an unexpected or ambiguous compiled-dist shape; -- generic private file stores remain owner-only at upstream `0700` directory - and `0600` file modes, including device identity, device authentication, and - credential-profile paths. The shared-state marker does not broaden these - security boundaries to the sandbox group; +- the patch leaves upstream private-store enforcement unchanged, including its + `0700` directory and `0600` file modes for device identity, device + authentication, and credential-profile paths. The shared-state marker is not + consulted by those generic stores; - generated `models.json` is the reviewed exception to the generic private-store - rule. Under the validated NemoClaw marker, the compiled models-config patch + rule. Under the split-user NemoClaw marker, the compiled models-config patch keeps this non-secret provider configuration at `0660` and skips a non-owner - `chmod` when the inherited mode is already correct. Outside NemoClaw it - preserves the upstream `0600` behavior; -- the legacy update-check migration is skipped only under the same validated - NemoClaw or OpenShell marker. This state contains polling, notification, and - auto-install cache for an OpenClaw version that NemoClaw pins in the image; - all other startup migrations and the upstream behavior outside NemoClaw are - unchanged. + `chmod` when the inherited mode is already correct. Same-UID OpenShell and + runtimes outside NemoClaw preserve the upstream `0600` behavior; +- the legacy update-check migration is skipped only under the split-user marker + or a validated OpenShell marker. This state contains polling, notification, + and auto-install cache for an OpenClaw version that NemoClaw pins in the + image; all other startup migrations and the upstream behavior outside + NemoClaw are unchanged. `scripts/patch-openclaw-device-self-approval.mts` remains required. Its new shape recognizers preserve the bounded stored-device credential flow and keep @@ -181,6 +182,17 @@ follows: - a root entrypoint starts the `gateway` user with `HOME=/sandbox`, so startup migrations do not probe the inaccessible `/root/.openclaw` path. +Installed-base coverage is the `v0.0.89-x86_64` row in the +`openshell-gateway-upgrade` E2E matrix. It installs the immutable v0.0.89 +release with OpenClaw `2026.6.10`, seeds its legacy Memory Core SQLite and +update-check state, then upgrades through the current installer. The row proves +the `2026.7.1` startup checkpoint and per-agent database migration complete +with intact SQLite state, and that the restored `apiKey: "unused"` config +still receives its gateway-held credential only at the OpenShell boundary. +This custom route supplies `COMPATIBLE_API_KEY`, so the frozen v0.0.89 +runtime intentionally creates no NVIDIA auth-profile key reference; the E2E +preserves any references that do exist without inventing one for this route. + During image assembly, the shared-state repair rejects symbolic links, non-regular entries, and multiply linked files before it changes the ownership or mode of `exec-approvals.json` or SQLite state. This prevents a stale image diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index 7198bb2a91e..c34e2d157a8 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -10,7 +10,6 @@ import { cpSync, existsSync, fstatSync, - lstatSync, mkdirSync, mkdtempSync, openSync, @@ -146,31 +145,33 @@ function writeJson(path: string, value: JsonObject): void { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); } -function hashPackageTree(packageDirectory: string): string { +// The package tree lives below the caller's freshly created 0700 remediation +// root. Pin each regular file's type and contents to the same no-follow +// descriptor, and reject special entries after a nonblocking open. +export function hashPackageTree(packageDirectory: string): string { const hash = createHash("sha512"); const visit = (directory: string, relativeDirectory: string): void => { for (const name of readdirSync(directory).sort()) { const absolutePath = join(directory, name); const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name; - const stats = lstatSync(absolutePath); - if (stats.isDirectory()) { - hash.update(`directory\0${relativePath}\0`); - visit(absolutePath, relativePath); - } else if (stats.isFile()) { - const descriptor = openSync(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - const openedStats = fstatSync(descriptor); - if (!openedStats.isFile()) { - throw new Error(`Remediated package tree has unsupported entry ${relativePath}`); - } + const descriptor = openSync( + absolutePath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const openedStats = fstatSync(descriptor); + if (openedStats.isDirectory()) { + hash.update(`directory\0${relativePath}\0`); + visit(absolutePath, relativePath); + } else if (openedStats.isFile()) { hash.update(`file\0${relativePath}\0${openedStats.size}\0`); hash.update(readFileSync(descriptor)); hash.update("\0"); - } finally { - closeSync(descriptor); + } else { + throw new Error(`Remediated package tree has unsupported entry ${relativePath}`); } - } else { - throw new Error(`Remediated package tree has unsupported entry ${relativePath}`); + } finally { + closeSync(descriptor); } } }; diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 51f2462f3f9..b94309d729e 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3404,6 +3404,13 @@ PROXYEOF _escaped_openclaw_env_value="$(printf '%s' "$_openclaw_env_value" | sed "s/'/'\\\\''/g")" printf "export %s='%s'\n" "$_openclaw_env_name" "$_escaped_openclaw_env_value" done + if [ "${NEMOCLAW_OPENCLAW_SHARED_STATE:-}" = "1" ]; then + printf 'export NEMOCLAW_OPENCLAW_SHARED_STATE=1\n' + else + # Old/custom images may still carry the former image-wide marker. Keep + # connect shells aligned with the topology selected by PID 1. + printf 'unset NEMOCLAW_OPENCLAW_SHARED_STATE\n' + fi if [ -n "${OPENCLAW_GATEWAY_PORT:-}" ]; then _escaped_gateway_port="$(printf '%s' "$OPENCLAW_GATEWAY_PORT" | sed "s/'/'\\\\''/g")" printf "export OPENCLAW_GATEWAY_PORT='%s'\n" "$_escaped_gateway_port" @@ -5377,6 +5384,17 @@ handle_openclaw_gateway_control_request() { # ── Main ───────────────────────────────────────────────────────── +# OpenClaw 2026.7.1 enforces owner-only SQLite and models-file modes on every +# open. Only the root entrypoint uses NemoClaw's separate sandbox/gateway UIDs; +# OpenShell starts this entrypoint as the sandbox UID and runs both roles as that +# same user. Derive the compatibility marker from the real topology instead of +# an image-wide or caller-supplied environment marker. +if [ "$(id -u)" -eq 0 ]; then + export NEMOCLAW_OPENCLAW_SHARED_STATE=1 +else + unset NEMOCLAW_OPENCLAW_SHARED_STATE +fi + # Begin the root PID 1 readiness lease before any startup path reads or mutates # OpenClaw config. Recovery runs before the locked-parent discriminator so a # crash in a prior config write/restart/handoff can complete deterministically. diff --git a/scripts/patch-openclaw-shared-state-permissions.mts b/scripts/patch-openclaw-shared-state-permissions.mts index 26a9df235b2..8c66f45124a 100755 --- a/scripts/patch-openclaw-shared-state-permissions.mts +++ b/scripts/patch-openclaw-shared-state-permissions.mts @@ -5,13 +5,13 @@ /* * Temporary compatibility patch for OpenClaw 2026.7.1 split-user state. * - * NemoClaw intentionally runs the OpenClaw CLI and gateway as separate users - * in the same group. OpenClaw 2026.7.1 makes shared and per-agent SQLite state - * part of gateway startup, but hardens those paths to owner-only modes. Use - * group-shared modes for those databases inside the NemoClaw image or an - * OpenShell sandbox, keep generic credential and identity stores owner-only, - * and ignore only the obsolete pinned-version update cache when its migration - * cannot archive through a shields-protected parent. + * NemoClaw's root entrypoint runs the OpenClaw CLI and gateway as separate + * users in the same group. OpenClaw 2026.7.1 makes shared and per-agent SQLite + * state part of gateway startup, but hardens those paths to owner-only modes. + * For that topology, keep generic credential and identity stores owner-only + * while applying group-shared modes only to the databases. Leave private-store + * enforcement unchanged, and ignore only the obsolete pinned-version update + * cache when its migration cannot archive through a shields-protected parent. * * Remove this patch once upstream supports a group-shared state database for * split-user containers without requiring a non-owner to chmod an already @@ -32,6 +32,13 @@ export const MODELS_MARKER = "/* nemoclaw: group-shared OpenClaw models file */" const GROUP_SHARED_ENV_HELPER = [ "function nemoclawUsesGroupSharedState(env) {", "\tconst nemoclawSharedStateMarker = env?.NEMOCLAW_OPENCLAW_SHARED_STATE ?? process.env.NEMOCLAW_OPENCLAW_SHARED_STATE;", + '\treturn nemoclawSharedStateMarker === "1";', + "}", +].join("\n"); + +const MANAGED_RUNTIME_ENV_HELPER = [ + "function nemoclawUsesManagedRuntime(env) {", + "\tconst nemoclawSharedStateMarker = env?.NEMOCLAW_OPENCLAW_SHARED_STATE ?? process.env.NEMOCLAW_OPENCLAW_SHARED_STATE;", "\tconst nemoclawOpenShellMarker = env?.OPENSHELL_SANDBOX ?? process.env.OPENSHELL_SANDBOX;", '\treturn nemoclawSharedStateMarker === "1" || nemoclawOpenShellMarker === "1" || (typeof nemoclawOpenShellMarker === "string" && /^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(nemoclawOpenShellMarker));', "}", @@ -111,7 +118,6 @@ const PATCHED_STATE_REQUIRED_PATTERNS = [ "const NEMOCLAW_SHARED_STATE_FILE_MODE = 0o660;", "function nemoclawUsesGroupSharedState(env) {", "env?.NEMOCLAW_OPENCLAW_SHARED_STATE ?? process.env.NEMOCLAW_OPENCLAW_SHARED_STATE", - "env?.OPENSHELL_SANDBOX ?? process.env.OPENSHELL_SANDBOX", "function bestEffortChmodSync(target, mode, skipWhenModeMatches = false) {", "(statSync(target).mode & 0o7777) === mode", "const nemoclawGroupSharedState = nemoclawUsesGroupSharedState(env);", @@ -194,19 +200,19 @@ const UPSTREAM_MIGRATION_START = [ ].join("\n"); const PATCHED_MIGRATION_START = [ - GROUP_SHARED_ENV_HELPER, + MANAGED_RUNTIME_ENV_HELPER, UPSTREAM_MIGRATION_FUNCTION_START, - `\tif (nemoclawUsesGroupSharedState()) return { changes, warnings }; ${MIGRATION_MARKER}`, + `\tif (nemoclawUsesManagedRuntime()) return { changes, warnings }; ${MIGRATION_MARKER}`, "\tif (!fileExists(params.detected.sourcePath)) return {", ].join("\n"); const PATCHED_MIGRATION_REQUIRED_PATTERNS = [ MIGRATION_MARKER, - "function nemoclawUsesGroupSharedState(env) {", + "function nemoclawUsesManagedRuntime(env) {", "env?.NEMOCLAW_OPENCLAW_SHARED_STATE ?? process.env.NEMOCLAW_OPENCLAW_SHARED_STATE", "env?.OPENSHELL_SANDBOX ?? process.env.OPENSHELL_SANDBOX", "function migrateLegacyUpdateCheckState(params) {", - "if (nemoclawUsesGroupSharedState()) return { changes, warnings };", + "if (nemoclawUsesManagedRuntime()) return { changes, warnings };", ] as const; const UPSTREAM_MODELS_FILE_MODE_HELPER = [ @@ -231,7 +237,6 @@ const PATCHED_MODELS_REQUIRED_PATTERNS = [ MODELS_MARKER, "function nemoclawUsesGroupSharedState(env) {", "env?.NEMOCLAW_OPENCLAW_SHARED_STATE ?? process.env.NEMOCLAW_OPENCLAW_SHARED_STATE", - "env?.OPENSHELL_SANDBOX ?? process.env.OPENSHELL_SANDBOX", "async function ensureModelsFileModeForModelsJson(pathname) {", "const nemoclawModelsFileMode = nemoclawGroupSharedState ? 0o660 : 384;", "((await fs.stat(pathname)).mode & 0o7777) === nemoclawModelsFileMode", diff --git a/test/e2e/live/openshell-gateway-upgrade-helpers.ts b/test/e2e/live/openshell-gateway-upgrade-helpers.ts index fceeda39f43..658795a8335 100644 --- a/test/e2e/live/openshell-gateway-upgrade-helpers.ts +++ b/test/e2e/live/openshell-gateway-upgrade-helpers.ts @@ -67,6 +67,8 @@ export function expectedLegacyRegistryMetadata(nemoclawRef: string): { return { nemoclawVersion: undefined, fromDockerfile: undefined }; case "v0.0.74": return { nemoclawVersion: "0.0.74", fromDockerfile: null }; + case "v0.0.89": + return { nemoclawVersion: "0.0.89", fromDockerfile: null }; default: throw new Error(`Unsupported gateway-upgrade registry fixture: ${nemoclawRef}`); } diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index d2e66f11cb6..5a4727f874f 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -29,7 +29,10 @@ import type { HostCliClient } from "../fixtures/clients/host.ts"; import { resultText } from "../fixtures/clients/index.ts"; import { validateSandboxName } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; -import { startFakeOpenAiCompatibleServer } from "../fixtures/fake-openai-compatible.ts"; +import { + type FakeOpenAiCompatibleServer, + startFakeOpenAiCompatibleServer, +} from "../fixtures/fake-openai-compatible.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { @@ -62,6 +65,9 @@ const OLD_SANDBOX_BASE_IMAGE_REF = process.env.NEMOCLAW_OLD_SANDBOX_BASE_IMAGE_REF ?? "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:104151ffadc2ff0b6c815e3c95c2783ced61aee0d0f83fc327cc02be9b7e14e6"; const OLD_OPENCLAW_VERSION = process.env.NEMOCLAW_OLD_OPENCLAW_VERSION ?? "2026.4.24"; +const CURRENT_OPENCLAW_VERSION = process.env.NEMOCLAW_CURRENT_OPENCLAW_VERSION ?? ""; +const OPENCLAW_STATE_UPGRADE_PROOF = + process.env.NEMOCLAW_OPENCLAW_STATE_UPGRADE_PROOF === "1"; const { sandboxBaseDigest: OLD_SANDBOX_BASE_DIGEST } = validateLegacyGatewayUpgradeFixture({ nemoclawRef: OLD_NEMOCLAW_REF, nemoclawCommit: OLD_NEMOCLAW_COMMIT, @@ -81,8 +87,15 @@ const SURVIVOR_SANDBOX = .join("-"); const SURVIVOR_MARKER = `gateway-upgrade-survivor-${Date.now()}`; const SURVIVOR_MARKER_PATH = "/sandbox/.openclaw/workspace/nemoclaw-gateway-upgrade-marker"; +const INSTALLED_AGENT_DB_MARKER = `openclaw-2026-6-agent-db-${Date.now()}`; +const LEGACY_MEMORY_MARKER = `openclaw-2026-6-memory-${Date.now()}`; +const LEGACY_MEMORY_SIDECAR = "/sandbox/.openclaw/memory/main.sqlite"; +const OPENCLAW_GLOBAL_STATE_DB = "/sandbox/.openclaw/state/openclaw.sqlite"; +const OPENCLAW_MAIN_AGENT_DB = + "/sandbox/.openclaw/agents/main/agent/openclaw-agent.sqlite"; +const LEGACY_UPDATE_CHECK_PATH = "/sandbox/.openclaw/update-check.json"; const REGISTRY_FILE = path.join(os.homedir(), ".nemoclaw", "sandboxes.json"); -const TEST_TIMEOUT_MS = 60 * 60_000; +const TEST_TIMEOUT_MS = 65 * 60_000; const INSTALL_TIMEOUT_MS = 35 * 60_000; const OPENSHELL_TIMEOUT_MS = 2 * 60_000; @@ -91,6 +104,11 @@ expect( SURVIVOR_SANDBOX.startsWith("e2e-gateway-upgrade-survivor"), `openshell-gateway-upgrade live test only accepts survivor sandbox names with prefix e2e-gateway-upgrade-survivor; got ${SURVIVOR_SANDBOX}`, ).toBe(true); +if (OPENCLAW_STATE_UPGRADE_PROOF) { + expect(OLD_NEMOCLAW_REF).toBe("v0.0.89"); + expect(OLD_OPENCLAW_VERSION).toBe("2026.6.10"); + expect(CURRENT_OPENCLAW_VERSION).toBe("2026.7.1"); +} function writeExecutable(target: string, contents: string): void { fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); @@ -185,6 +203,239 @@ async function bash( ); } +interface OpenClawStateContract { + agentDbIntegrity: string; + apiKey: unknown; + globalDbIntegrity: string; + installedAgentDbMarker: string | null; + keyRefIds: string[]; + legacyMemoryMarker: string | null; + legacyMemorySidecarArchived: boolean; + legacyMemorySidecarPresent: boolean; + literalSecretEnvKeys: string[]; + literalSecretInState: boolean; + placeholderEnvKeys: string[]; + startupCheckpoint: string | null; + uid: number; + updateCheckPresent: boolean; + version: string; +} + +function encodedNodeCommand(source: string): string { + const payload = Buffer.from(source, "utf8").toString("base64"); + return `printf '%s' ${shellQuote(payload)} | base64 -d | NODE_NO_WARNINGS=1 node`; +} + +async function runInSurvivorSandbox( + host: HostCliClient, + command: string, + options: { artifactName: string; currentCli?: boolean; timeoutMs?: number }, +): Promise { + const prefix = options.currentCli + ? `nemoclaw ${shellQuote(SURVIVOR_SANDBOX)} exec --` + : `openshell sandbox exec --name ${shellQuote(SURVIVOR_SANDBOX)} --`; + return bash(host, `${prefix} sh -lc ${shellQuote(command)}`, { + artifactName: options.artifactName, + redactionValues: ["dummy"], + timeoutMs: options.timeoutMs ?? 60_000, + }); +} + +async function inspectOpenClawStateContract( + host: HostCliClient, + phase: "legacy" | "upgraded", +): Promise { + const seedLegacyUpdateCheck = phase === "legacy"; + const source = String.raw` +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const { DatabaseSync } = require("node:sqlite"); +const configPath = "/sandbox/.openclaw/openclaw.json"; +const authPath = "/sandbox/.openclaw/agents/main/agent/auth-profiles.json"; +const globalDbPath = ${JSON.stringify(OPENCLAW_GLOBAL_STATE_DB)}; +const agentDbPath = ${JSON.stringify(OPENCLAW_MAIN_AGENT_DB)}; +const installedAgentDbMarkerValue = ${JSON.stringify(INSTALLED_AGENT_DB_MARKER)}; +const legacyMemoryPath = ${JSON.stringify(LEGACY_MEMORY_SIDECAR)}; +const legacyMemoryMarkerValue = ${JSON.stringify(LEGACY_MEMORY_MARKER)}; +const updateCheckPath = ${JSON.stringify(LEGACY_UPDATE_CHECK_PATH)}; +const configText = fs.readFileSync(configPath, "utf8"); +const authText = fs.existsSync(authPath) ? fs.readFileSync(authPath, "utf8") : "{}"; +const config = JSON.parse(configText); +const auth = JSON.parse(authText); +if (${JSON.stringify(seedLegacyUpdateCheck)}) { + fs.writeFileSync(updateCheckPath, JSON.stringify({ + lastCheckedAt: "2026-07-20T00:00:00.000Z", + lastAvailableVersion: "2026.7.1", + }) + "\n", { mode: 0o600 }); +} +const globalDb = new DatabaseSync(globalDbPath, { readOnly: true }); +const globalDbIntegrity = globalDb.prepare("PRAGMA integrity_check").get().integrity_check; +const hasSchemaMeta = globalDb + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get("schema_meta"); +const startupCheckpoint = hasSchemaMeta + ? globalDb + .prepare("SELECT app_version AS appVersion FROM schema_meta WHERE meta_key = ?") + .get("startup-migrations")?.appVersion ?? null + : null; +globalDb.close(); +const agentDb = new DatabaseSync(agentDbPath, { + readOnly: !${JSON.stringify(seedLegacyUpdateCheck)}, +}); +agentDb.exec("PRAGMA busy_timeout = 5000"); +if (${JSON.stringify(seedLegacyUpdateCheck)}) { + // cache_entries is present with this exact shape in both published versions; + // a non-expiring private scope proves the real 6.10 agent DB was restored + // without being coupled to Memory Core's normal reindex cleanup. + agentDb.prepare("INSERT OR REPLACE INTO cache_entries (scope, key, value_json, blob, expires_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?)") + .run("nemoclaw-e2e-state-upgrade", "installed-agent-db-marker", JSON.stringify(installedAgentDbMarkerValue), 1); +} +const agentDbIntegrity = agentDb.prepare("PRAGMA integrity_check").get().integrity_check; +const installedAgentDbMarkerJson = agentDb + .prepare("SELECT value_json AS valueJson FROM cache_entries WHERE scope = ? AND key = ?") + .get("nemoclaw-e2e-state-upgrade", "installed-agent-db-marker")?.valueJson; +const installedAgentDbMarker = + typeof installedAgentDbMarkerJson === "string" ? JSON.parse(installedAgentDbMarkerJson) : null; +let legacyMemoryMarker = null; +if (${JSON.stringify(seedLegacyUpdateCheck)}) { + fs.mkdirSync(path.dirname(legacyMemoryPath), { recursive: true }); + const legacyMemoryDb = new DatabaseSync(legacyMemoryPath); + legacyMemoryDb.exec([ + "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)", + "CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, source TEXT NOT NULL, hash TEXT NOT NULL, mtime INTEGER NOT NULL, size INTEGER NOT NULL)", + "CREATE TABLE IF NOT EXISTS chunks (id TEXT PRIMARY KEY, path TEXT NOT NULL, source TEXT NOT NULL, start_line INTEGER NOT NULL, end_line INTEGER NOT NULL, hash TEXT NOT NULL, model TEXT NOT NULL, text TEXT NOT NULL, embedding TEXT NOT NULL, updated_at INTEGER NOT NULL)", + ].join(";\n")); + legacyMemoryDb.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)") + .run("nemoclaw-e2e-state-upgrade", legacyMemoryMarkerValue); + legacyMemoryDb.prepare("INSERT OR REPLACE INTO files (path, source, hash, mtime, size) VALUES (?, ?, ?, ?, ?)") + .run("memory/nemoclaw-e2e.md", "memory", "nemoclaw-e2e-hash", 1, legacyMemoryMarkerValue.length); + legacyMemoryDb.prepare("INSERT OR REPLACE INTO chunks (id, path, source, start_line, end_line, hash, model, text, embedding, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run("nemoclaw-e2e-legacy-memory", "memory/nemoclaw-e2e.md", "memory", 1, 1, "nemoclaw-e2e-hash", "none", legacyMemoryMarkerValue, "[]", 1); + legacyMemoryDb.close(); + fs.chmodSync(legacyMemoryPath, 0o600); + legacyMemoryMarker = legacyMemoryMarkerValue; +} else { + legacyMemoryMarker = agentDb + .prepare("SELECT text FROM memory_index_chunks WHERE id = ?") + .get("nemoclaw-e2e-legacy-memory")?.text ?? null; +} +agentDb.close(); +const keyRefIds = []; +function collectKeyRefs(value) { + if (!value || typeof value !== "object") return; + if (value.keyRef?.source === "env" && typeof value.keyRef.id === "string") { + keyRefIds.push(value.keyRef.id); + } + for (const child of Object.values(value)) collectKeyRefs(child); +} +collectKeyRefs(config); +collectKeyRefs(auth); +const envEntries = Object.entries(process.env); +console.log(JSON.stringify({ + agentDbIntegrity, + apiKey: config.models?.providers?.inference?.apiKey, + globalDbIntegrity, + installedAgentDbMarker, + keyRefIds: [...new Set(keyRefIds)].sort(), + legacyMemoryMarker, + legacyMemorySidecarArchived: fs.existsSync(legacyMemoryPath + ".migrated"), + legacyMemorySidecarPresent: fs.existsSync(legacyMemoryPath), + literalSecretEnvKeys: envEntries.filter(([, value]) => value === "dummy").map(([key]) => key), + literalSecretInState: configText.includes("dummy") || authText.includes("dummy"), + placeholderEnvKeys: envEntries + .filter(([, value]) => typeof value === "string" && value.startsWith("openshell:resolve:env:")) + .map(([key]) => key) + .sort(), + startupCheckpoint, + uid: process.getuid(), + updateCheckPresent: fs.existsSync(updateCheckPath), + version: execFileSync("openclaw", ["--version"], { encoding: "utf8" }).trim(), +})); +`; + const result = await runInSurvivorSandbox(host, encodedNodeCommand(source), { + artifactName: `state-upgrade-${phase}-contract`, + currentCli: phase === "upgraded", + }); + expectExitZero(result, `${phase} OpenClaw state contract inspection`); + const json = result.stdout.trim().split("\n").at(-1) ?? ""; + expect(json, `${phase} OpenClaw state contract must emit JSON`).not.toBe(""); + const summary = JSON.parse(json) as OpenClawStateContract; + expect(summary.uid, `${phase} contract must run as the sandbox user`).toBeGreaterThan(0); + expect(summary.apiKey, `${phase} custom-provider config must retain the proxy sentinel`).toBe( + "unused", + ); + expect(summary.literalSecretEnvKeys).toEqual([]); + expect(summary.literalSecretInState).toBe(false); + expect(summary.updateCheckPresent).toBe(seedLegacyUpdateCheck); + expect(summary.globalDbIntegrity).toBe("ok"); + expect(summary.agentDbIntegrity).toBe("ok"); + expect(summary.installedAgentDbMarker).toBe(INSTALLED_AGENT_DB_MARKER); + if (phase === "legacy") { + expect(summary.startupCheckpoint).toBeNull(); + expect(summary.legacyMemoryMarker).toBe(LEGACY_MEMORY_MARKER); + expect(summary.legacyMemorySidecarPresent).toBe(true); + expect(summary.legacyMemorySidecarArchived).toBe(false); + } else { + expect(summary.startupCheckpoint).toBe(CURRENT_OPENCLAW_VERSION); + expect(summary.legacyMemoryMarker).toBe(LEGACY_MEMORY_MARKER); + expect(summary.legacyMemorySidecarPresent).toBe(false); + expect(summary.legacyMemorySidecarArchived).toBe(true); + } + const versionToken = summary.version.match(/\b\d{4}\.\d{1,2}\.\d{1,2}\b/)?.[0]; + expect(versionToken).toBe(phase === "legacy" ? OLD_OPENCLAW_VERSION : CURRENT_OPENCLAW_VERSION); + return summary; +} + +function expectOptionalStateReferencesPreserved( + legacy: OpenClawStateContract, + upgraded: OpenClawStateContract, +): void { + // This custom-provider fixture sets COMPATIBLE_API_KEY, not + // NVIDIA_INFERENCE_API_KEY, so v0.0.89 intentionally does not create the + // NVIDIA auth-profile keyRef. Preserve any references the frozen runtime + // does emit without inventing one for this route. + for (const keyRefId of legacy.keyRefIds) { + expect(upgraded.keyRefIds).toContain(keyRefId); + } + for (const envKey of legacy.placeholderEnvKeys) { + expect(upgraded.placeholderEnvKeys).toContain(envKey); + } +} + +async function assertOpenClawAgentSecretBoundary( + host: HostCliClient, + fake: FakeOpenAiCompatibleServer, + phase: "legacy" | "upgraded", +): Promise { + const requestOffset = fake.requests().length; + const agent = await runInSurvivorSandbox( + host, + `openclaw agent --agent main --json --thinking off --session-id ${shellQuote( + `e2e-state-upgrade-${phase}`, + )} -m ${shellQuote("Reply with only: ok")}`, + { + artifactName: `state-upgrade-${phase}-agent`, + currentCli: phase === "upgraded", + timeoutMs: 120_000, + }, + ); + expectExitZero(agent, `${phase} sandbox-user OpenClaw agent turn`); + expect(resultText(agent).toLowerCase()).toContain("ok"); + const requests = fake + .requests() + .slice(requestOffset) + .filter((request) => request.path.includes("/chat/completions")); + expect(requests.length, `${phase} agent turn must reach the compatible endpoint`).toBeGreaterThan( + 0, + ); + expect(requests.some((request) => request.auth === "Bearer dummy")).toBe(true); + expect(requests.some((request) => request.auth === "Bearer unused")).toBe(false); + expect( + requests.some((request) => request.auth?.includes("openshell:resolve:env:")), + ).toBe(false); +} + // The frozen release installers are the source of truth, but their embedded // Dockerfiles predate the fixture pins needed for a deterministic upgrade test. // Keep this adapter scoped to the frozen historical lanes and retire it with @@ -466,7 +717,10 @@ async function installOldNemoclawAndClaw( `downloaded ${OLD_NEMOCLAW_REF} installer must match its pinned SHA-256`, ).toBe(OLD_INSTALLER_SHA256); fs.chmodSync(oldInstaller, 0o755); - patchOldInstallerFixture(oldInstaller); + // v0.0.89 already contains the reviewed 2026.6.10 integrity/tarball pins. + // Keep it byte-representative of the installed base instead of injecting + // the legacy fixture adapter used by the established older lanes. + if (OLD_NEMOCLAW_REF !== "v0.0.89") patchOldInstallerFixture(oldInstaller); const installEnv = liveEnv({ PATH: `${wrapperDir}:${process.env.PATH ?? "/usr/bin:/bin"}`, @@ -856,6 +1110,12 @@ runLinuxOpenShellGatewayUpgrade( oldOpenClawVersion: OLD_OPENCLAW_VERSION, oldSandboxBaseImageRef: OLD_SANDBOX_BASE_IMAGE_REF, currentOpenShellVersion: CURRENT_OPENSHELL_VERSION, + ...(OPENCLAW_STATE_UPGRADE_PROOF + ? { + currentOpenClawVersion: CURRENT_OPENCLAW_VERSION, + openClawStateUpgrade: "2026.6.10 installed state to 2026.7.1", + } + : {}), survivorSandbox: SURVIVOR_SANDBOX, }); @@ -889,6 +1149,8 @@ runLinuxOpenShellGatewayUpgrade( host: "0.0.0.0", model: "test-model", publicHost: "host.openshell.internal", + requireAuth: OPENCLAW_STATE_UPGRADE_PROOF, + requireAuthModels: OPENCLAW_STATE_UPGRADE_PROOF, responseText: "ok", }); cleanup.add("close compatible endpoint mock", async () => { @@ -900,6 +1162,12 @@ runLinuxOpenShellGatewayUpgrade( }); await installOldNemoclawAndClaw(host, artifacts, fake.baseUrl); + let legacyStateContract: OpenClawStateContract | undefined; + if (OPENCLAW_STATE_UPGRADE_PROOF) { + await assertOpenClawAgentSecretBoundary(host, fake, "legacy"); + legacyStateContract = await inspectOpenClawStateContract(host, "legacy"); + await artifacts.writeJson("openclaw-2026-6-state-contract.json", legacyStateContract); + } const hiddenOldOpenShellDir = OLD_NEMOCLAW_REF === "v0.0.55" ? await stageOldOpenShellInUserLocalBin(host) : undefined; const survivorPid = await startSurvivorAgentInExistingClaw(host); @@ -911,6 +1179,12 @@ runLinuxOpenShellGatewayUpgrade( hiddenOldOpenShellDir, ); await assertSurvivorSandboxAfterUpgrade(host); + if (OPENCLAW_STATE_UPGRADE_PROOF) { + const upgradedStateContract = await inspectOpenClawStateContract(host, "upgraded"); + expectOptionalStateReferencesPreserved(legacyStateContract!, upgradedStateContract); + await artifacts.writeJson("openclaw-2026-7-state-contract.json", upgradedStateContract); + await assertOpenClawAgentSecretBoundary(host, fake, "upgraded"); + } }, ); diff --git a/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts b/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts index 375278ef811..698fd574463 100644 --- a/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts +++ b/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts @@ -38,7 +38,11 @@ describe("OpenShell gateway upgrade workflow boundary", () => { "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:104151ffadc2ff0b6c815e3c95c2783ced61aee0d0f83fc327cc02be9b7e14e6"; fixtures.find((fixture) => fixture.id === "v0.0.55-aarch64")!.runner = "ubuntu-latest"; fixtures.find((fixture) => fixture.id === "v0.0.74-x86_64")!.openclaw_version = "latest"; - (job.env as Record).NEMOCLAW_E2E_SHARD = "default"; + fixtures.find((fixture) => fixture.id === "v0.0.89-x86_64")!.openclaw_state_upgrade = "0"; + const env = job.env as Record; + env.NEMOCLAW_E2E_SHARD = "default"; + env.NEMOCLAW_CURRENT_OPENCLAW_VERSION = "latest"; + env.NEMOCLAW_OPENCLAW_STATE_UPGRADE_PROOF = "0"; const run = (job.steps as Array>).find( (step) => step.name === "Run OpenShell gateway upgrade live Vitest test", )!; @@ -49,7 +53,10 @@ describe("OpenShell gateway upgrade workflow boundary", () => { "openshell-gateway-upgrade must run on ${{ matrix.runner }}", "openshell-gateway-upgrade v0.0.55 matrix must pin x86_64 and arm64 upgrade fixtures", "openshell-gateway-upgrade matrix must pin the immediate v0.0.74 x86_64 upgrade fixture", + "openshell-gateway-upgrade matrix must pin the v0.0.89 OpenClaw state-upgrade fixture", "openshell-gateway-upgrade must publish one risk-signal shard per legacy fixture", + "openshell-gateway-upgrade must bind the current OpenClaw version from its fixture", + "openshell-gateway-upgrade must bind the OpenClaw state-upgrade proof flag from its fixture", "openshell-gateway-upgrade step 'Run OpenShell gateway upgrade live Vitest test' must run: npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/openshell-gateway-upgrade.test.ts", ]), ); @@ -80,7 +87,11 @@ describe("OpenShell gateway upgrade workflow boundary", () => { nemoclawVersion: "0.0.74", fromDockerfile: null, }); - expect(() => expectedLegacyRegistryMetadata("v0.0.75")).toThrow( + expect(expectedLegacyRegistryMetadata("v0.0.89")).toEqual({ + nemoclawVersion: "0.0.89", + fromDockerfile: null, + }); + expect(() => expectedLegacyRegistryMetadata("v0.0.90")).toThrow( /Unsupported gateway-upgrade registry fixture/, ); }); diff --git a/test/nemoclaw-start-shared-state-topology.test.ts b/test/nemoclaw-start-shared-state-topology.test.ts new file mode 100644 index 00000000000..673257df558 --- /dev/null +++ b/test/nemoclaw-start-shared-state-topology.test.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const START_SCRIPT = path.join(process.cwd(), "scripts", "nemoclaw-start.sh"); + +function sourceBlock(source: string, startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + if (start === -1 || end === -1 || end <= start) { + throw new Error(`Expected ${startMarker} before ${endMarker} in nemoclaw-start.sh`); + } + return source.slice(start, end); +} + +function runBash(lines: string[]): SpawnSyncReturns { + return spawnSync("bash", ["-c", ["set -euo pipefail", ...lines].join("\n")], { + encoding: "utf-8", + timeout: 5000, + }); +} + +describe("nemoclaw-start shared-state topology (#7280)", () => { + const source = fs.readFileSync(START_SCRIPT, "utf-8"); + + it.each([ + { expected: "1", initial: "caller-disabled", uid: 0 }, + { expected: "unset", initial: "1", uid: 1000 }, + ])("derives marker $expected for uid $uid", ({ expected, initial, uid }) => { + const block = sourceBlock( + source, + "# OpenClaw 2026.7.1 enforces owner-only SQLite", + "# Begin the root PID 1 readiness lease", + ); + const result = runBash([ + `id() { if [ "\${1:-}" = "-u" ]; then printf ${JSON.stringify(String(uid))}; else command id "$@"; fi; }`, + `export NEMOCLAW_OPENCLAW_SHARED_STATE=${JSON.stringify(initial)}`, + block, + 'printf "%s\\n" "${NEMOCLAW_OPENCLAW_SHARED_STATE:-unset}"', + ]); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(expected); + }); + + it.each([ + { expected: "export NEMOCLAW_OPENCLAW_SHARED_STATE=1", marker: "1" }, + { expected: "unset NEMOCLAW_OPENCLAW_SHARED_STATE", marker: "" }, + ])("writes connect-shell command: $expected", ({ expected, marker }) => { + const block = sourceBlock( + source, + ' if [ "${NEMOCLAW_OPENCLAW_SHARED_STATE:-}" = "1" ]; then', + ' if [ -n "${OPENCLAW_GATEWAY_PORT:-}" ]; then', + ); + const markerCommand = marker + ? "export NEMOCLAW_OPENCLAW_SHARED_STATE=1" + : "unset NEMOCLAW_OPENCLAW_SHARED_STATE"; + const result = runBash([markerCommand, block]); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(expected); + }); +}); diff --git a/test/openclaw-npm-remediation.test.ts b/test/openclaw-npm-remediation.test.ts index f7158f0dd8a..8bef030f728 100644 --- a/test/openclaw-npm-remediation.test.ts +++ b/test/openclaw-npm-remediation.test.ts @@ -2,11 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { patchOpenClawPluginPackageGraph } from "../scripts/lib/openclaw-npm-remediation.mts"; +import { + hashPackageTree, + patchOpenClawPluginPackageGraph, +} from "../scripts/lib/openclaw-npm-remediation.mts"; const temporaryDirectories: string[] = []; @@ -79,6 +82,46 @@ afterEach(() => { }); describe("OpenClaw npm remediation", () => { + it("hashes package entries through opened file descriptors", () => { + const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-tree-integrity-")); + temporaryDirectories.push(directory); + mkdirSync(path.join(directory, "nested")); + writeFileSync(path.join(directory, "package.json"), '{"name":"fixture"}\n'); + writeFileSync(path.join(directory, "nested", "content.txt"), "reviewed content\n"); + + const first = hashPackageTree(directory); + const second = hashPackageTree(directory); + + expect(first).toMatch(/^sha512-/); + expect(second).toBe(first); + }); + + it("rejects symbolic links in a remediated package tree", () => { + const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-tree-symlink-")); + temporaryDirectories.push(directory); + const outside = path.join(directory, "..", `${path.basename(directory)}-outside`); + writeFileSync(outside, "must not be hashed\n"); + temporaryDirectories.push(outside); + symlinkSync(outside, path.join(directory, "linked-content")); + + expect(() => hashPackageTree(directory)).toThrow(); + }); + + it.skipIf(process.platform === "win32")( + "rejects FIFOs without blocking in a remediated package tree", + () => { + const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-tree-fifo-")); + temporaryDirectories.push(directory); + const fifo = path.join(directory, "blocked-reader"); + const created = spawnSync("mkfifo", [fifo], { encoding: "utf8", timeout: 5000 }); + expect(created.status, created.stderr).toBe(0); + + const startedAt = Date.now(); + expect(() => hashPackageTree(directory)).toThrow(/unsupported entry/); + expect(Date.now() - startedAt).toBeLessThan(1000); + }, + ); + it("replaces the reviewed bundled Axios graph with the patched graph", () => { const directory = writeFixture(); diff --git a/test/openclaw-shared-state-permissions-patch.test.ts b/test/openclaw-shared-state-permissions-patch.test.ts index a0fb79f0b8f..5f375905e28 100644 --- a/test/openclaw-shared-state-permissions-patch.test.ts +++ b/test/openclaw-shared-state-permissions-patch.test.ts @@ -406,7 +406,7 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { it.each([ "1", "sandbox-name", - ])("uses group-shared state modes for the OpenShell marker %s", async (openShellMarker) => { + ])("retains owner-only state modes for the same-UID OpenShell marker %s", async (openShellMarker) => { const fixture = makeFixture(); try { patchOpenClawSharedStatePermissions(fixture.dist); @@ -421,9 +421,9 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { fs.writeFileSync(wal, ""); runtime.ensureOpenClawStatePermissions(database, env); - expect(mode(stateDir)).toBe(0o2770); - expect(mode(database)).toBe(0o660); - expect(mode(wal)).toBe(0o660); + expect(mode(stateDir)).toBe(0o700); + expect(mode(database)).toBe(0o600); + expect(mode(wal)).toBe(0o600); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); } @@ -502,6 +502,32 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { } }); + it.each(["1", "sandbox-name"])( + "retains owner-only per-agent database modes in same-UID OpenShell %s", + async (openShellMarker) => { + const fixture = makeFixture(); + try { + patchOpenClawSharedStatePermissions(fixture.dist); + const runtime = await importAgentFixture(fixture.agentFiles[0]); + const agentDir = path.join(fixture.root, "openshell-agent-state"); + const database = path.join(agentDir, "main.sqlite"); + const options = { + agentId: "main", + env: { OPENCLAW_AGENT_DIR: agentDir, OPENSHELL_SANDBOX: openShellMarker }, + }; + + runtime.ensureOpenClawAgentDatabasePermissions(database, options); + fs.writeFileSync(database, ""); + runtime.ensureOpenClawAgentDatabasePermissions(database, options); + + expect(mode(agentDir)).toBe(0o700); + expect(mode(database)).toBe(0o600); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }, + ); + it("retains owner-only secret-file modes under the NemoClaw marker", async () => { const fixture = makeFixture(); const previousMarker = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; @@ -631,17 +657,14 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { } }); - it.each([ - "1", - "sandbox-name", - ])("keeps generated models files group-readable under the OpenShell marker %s", async (openShellMarker) => { + it("keeps generated models files group-readable under the split-user marker", async () => { const fixture = makeFixture(); const previousShared = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; const previousOpenShell = process.env.OPENSHELL_SANDBOX; try { patchOpenClawSharedStatePermissions(fixture.dist); - delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; - process.env.OPENSHELL_SANDBOX = openShellMarker; + process.env.NEMOCLAW_OPENCLAW_SHARED_STATE = "1"; + delete process.env.OPENSHELL_SANDBOX; const runtime = await importModelsFixture(fixture.modelsFiles[0]); const modelsFile = path.join(fixture.root, "models.json"); fs.writeFileSync(modelsFile, "{}", { mode: 0o600 }); @@ -660,14 +683,14 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { } }); - it("retains the upstream generated-models mode outside NemoClaw", async () => { + it.each(["1", "sandbox-name"])("retains the upstream generated-models mode in same-UID OpenShell %s", async (openShellMarker) => { const fixture = makeFixture(); const previousShared = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; const previousOpenShell = process.env.OPENSHELL_SANDBOX; try { patchOpenClawSharedStatePermissions(fixture.dist); delete process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; - process.env.OPENSHELL_SANDBOX = "sandbox_name"; + process.env.OPENSHELL_SANDBOX = openShellMarker; const runtime = await importModelsFixture(fixture.modelsFiles[0]); const modelsFile = path.join(fixture.root, "models.json"); fs.writeFileSync(modelsFile, "{}", { mode: 0o660 }); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index a61223e738a..10df24afacd 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -262,7 +262,7 @@ function runOpenclawStaleGroupFallback() { } describe("sandbox provisioning: runtime npm online state", () => { - it("replays the Dockerfile ENV directives for runtime npm and shared OpenClaw state", () => { + it("does not bake the split-user OpenClaw state marker into the runtime environment", () => { const exports = collectDockerfileEnvExports(DOCKERFILE); const probe = [ "#!/usr/bin/env bash", @@ -276,7 +276,7 @@ describe("sandbox provisioning: runtime npm online state", () => { fs.writeFileSync(scriptPath, probe, { mode: 0o700 }); const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); expect(result.status, `stderr: ${result.stderr}`).toBe(0); - expect(result.stdout.trim().split("\n")).toEqual(["false", "1"]); + expect(result.stdout.trim().split("\n")).toEqual(["false", "unset"]); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/tools/e2e/openshell-gateway-upgrade-workflow-boundary.mts b/tools/e2e/openshell-gateway-upgrade-workflow-boundary.mts index b34239c2c20..bf5fc49f93d 100644 --- a/tools/e2e/openshell-gateway-upgrade-workflow-boundary.mts +++ b/tools/e2e/openshell-gateway-upgrade-workflow-boundary.mts @@ -57,6 +57,21 @@ const EXPECTED_V074_FIXTURE: WorkflowRecord = { openclaw_version: "2026.5.27", }; +const EXPECTED_V089_FIXTURE: WorkflowRecord = { + id: "v0.0.89-x86_64", + runner: "ubuntu-latest", + shard: "v0-0-89-x86-64", + nemoclaw_ref: "v0.0.89", + nemoclaw_commit: "1143aa5cce77f3bad1b3b5588bd7fddbe438237e", + installer_sha256: "00f24959e5ca68104fe91221c0a015dab6a4154618497fa36b969b661f418cc2", + sandbox_base_image_ref: + "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:3265d482f67c9d81ee3a59b0bbad5eb5ea6c705fea81ece8ae888ed12794f7f1", + openshell_version: "0.0.85", + openclaw_version: "2026.6.10", + current_openclaw_version: "2026.7.1", + openclaw_state_upgrade: "1", +}; + function record(value: unknown): WorkflowRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as WorkflowRecord) @@ -81,6 +96,13 @@ function v074Fixture(job: WorkflowRecord): WorkflowRecord { : {}; } +function v089Fixture(job: WorkflowRecord): WorkflowRecord { + const include = record(record(job.strategy).matrix).include; + return Array.isArray(include) + ? (include.map(record).find((fixture) => fixture.nemoclaw_ref === "v0.0.89") ?? {}) + : {}; +} + function requireRunContains(errors: string[], step: WorkflowStep, fragment: string): void { if (!step.run?.includes(fragment)) { errors.push(`${JOB_NAME} step '${RUN_STEP_NAME}' must run: ${fragment}`); @@ -106,9 +128,19 @@ export function validateOpenShellGatewayUpgradeWorkflow(workflow: WorkflowRecord if (!isDeepStrictEqual(v074Fixture(job), EXPECTED_V074_FIXTURE)) { errors.push(`${JOB_NAME} matrix must pin the immediate v0.0.74 x86_64 upgrade fixture`); } - if (record(job.env).NEMOCLAW_E2E_SHARD !== "${{ matrix.shard }}") { + if (!isDeepStrictEqual(v089Fixture(job), EXPECTED_V089_FIXTURE)) { + errors.push(`${JOB_NAME} matrix must pin the v0.0.89 OpenClaw state-upgrade fixture`); + } + const env = record(job.env); + if (env.NEMOCLAW_E2E_SHARD !== "${{ matrix.shard }}") { errors.push(`${JOB_NAME} must publish one risk-signal shard per legacy fixture`); } + if (env.NEMOCLAW_CURRENT_OPENCLAW_VERSION !== "${{ matrix.current_openclaw_version }}") { + errors.push(`${JOB_NAME} must bind the current OpenClaw version from its fixture`); + } + if (env.NEMOCLAW_OPENCLAW_STATE_UPGRADE_PROOF !== "${{ matrix.openclaw_state_upgrade }}") { + errors.push(`${JOB_NAME} must bind the OpenClaw state-upgrade proof flag from its fixture`); + } const run = jobSteps(job).find((step) => step.name === RUN_STEP_NAME) ?? {}; requireRunContains(errors, run, RUN_COMMAND); From 2ac4fca0c5e82534d04c156fadf84d15f144efbd Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 08:06:51 -0700 Subject: [PATCH 12/24] test: satisfy conditional growth policy Signed-off-by: Aaron Erickson --- .../live/openshell-gateway-upgrade.test.ts | 114 ++++++++++++------ ...moclaw-start-shared-state-topology.test.ts | 9 +- 2 files changed, 84 insertions(+), 39 deletions(-) diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index 5a4727f874f..afe39bd46e5 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -66,8 +66,7 @@ const OLD_SANDBOX_BASE_IMAGE_REF = "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:104151ffadc2ff0b6c815e3c95c2783ced61aee0d0f83fc327cc02be9b7e14e6"; const OLD_OPENCLAW_VERSION = process.env.NEMOCLAW_OLD_OPENCLAW_VERSION ?? "2026.4.24"; const CURRENT_OPENCLAW_VERSION = process.env.NEMOCLAW_CURRENT_OPENCLAW_VERSION ?? ""; -const OPENCLAW_STATE_UPGRADE_PROOF = - process.env.NEMOCLAW_OPENCLAW_STATE_UPGRADE_PROOF === "1"; +const OPENCLAW_STATE_UPGRADE_PROOF = process.env.NEMOCLAW_OPENCLAW_STATE_UPGRADE_PROOF === "1"; const { sandboxBaseDigest: OLD_SANDBOX_BASE_DIGEST } = validateLegacyGatewayUpgradeFixture({ nemoclawRef: OLD_NEMOCLAW_REF, nemoclawCommit: OLD_NEMOCLAW_COMMIT, @@ -91,8 +90,7 @@ const INSTALLED_AGENT_DB_MARKER = `openclaw-2026-6-agent-db-${Date.now()}`; const LEGACY_MEMORY_MARKER = `openclaw-2026-6-memory-${Date.now()}`; const LEGACY_MEMORY_SIDECAR = "/sandbox/.openclaw/memory/main.sqlite"; const OPENCLAW_GLOBAL_STATE_DB = "/sandbox/.openclaw/state/openclaw.sqlite"; -const OPENCLAW_MAIN_AGENT_DB = - "/sandbox/.openclaw/agents/main/agent/openclaw-agent.sqlite"; +const OPENCLAW_MAIN_AGENT_DB = "/sandbox/.openclaw/agents/main/agent/openclaw-agent.sqlite"; const LEGACY_UPDATE_CHECK_PATH = "/sandbox/.openclaw/update-check.json"; const REGISTRY_FILE = path.join(os.homedir(), ".nemoclaw", "sandboxes.json"); const TEST_TIMEOUT_MS = 65 * 60_000; @@ -104,10 +102,16 @@ expect( SURVIVOR_SANDBOX.startsWith("e2e-gateway-upgrade-survivor"), `openshell-gateway-upgrade live test only accepts survivor sandbox names with prefix e2e-gateway-upgrade-survivor; got ${SURVIVOR_SANDBOX}`, ).toBe(true); -if (OPENCLAW_STATE_UPGRADE_PROOF) { - expect(OLD_NEMOCLAW_REF).toBe("v0.0.89"); - expect(OLD_OPENCLAW_VERSION).toBe("2026.6.10"); - expect(CURRENT_OPENCLAW_VERSION).toBe("2026.7.1"); +const stateUpgradeFixtureExpectations: ReadonlyArray = + OPENCLAW_STATE_UPGRADE_PROOF + ? [ + [OLD_NEMOCLAW_REF, "v0.0.89"], + [OLD_OPENCLAW_VERSION, "2026.6.10"], + [CURRENT_OPENCLAW_VERSION, "2026.7.1"], + ] + : []; +for (const [actual, expected] of stateUpgradeFixtureExpectations) { + expect(actual).toBe(expected); } function writeExecutable(target: string, contents: string): void { @@ -371,17 +375,24 @@ console.log(JSON.stringify({ expect(summary.globalDbIntegrity).toBe("ok"); expect(summary.agentDbIntegrity).toBe("ok"); expect(summary.installedAgentDbMarker).toBe(INSTALLED_AGENT_DB_MARKER); - if (phase === "legacy") { - expect(summary.startupCheckpoint).toBeNull(); - expect(summary.legacyMemoryMarker).toBe(LEGACY_MEMORY_MARKER); - expect(summary.legacyMemorySidecarPresent).toBe(true); - expect(summary.legacyMemorySidecarArchived).toBe(false); - } else { - expect(summary.startupCheckpoint).toBe(CURRENT_OPENCLAW_VERSION); - expect(summary.legacyMemoryMarker).toBe(LEGACY_MEMORY_MARKER); - expect(summary.legacyMemorySidecarPresent).toBe(false); - expect(summary.legacyMemorySidecarArchived).toBe(true); - } + const expectedPhaseContract = { + legacy: { + legacyMemorySidecarArchived: false, + legacyMemorySidecarPresent: true, + startupCheckpoint: null, + }, + upgraded: { + legacyMemorySidecarArchived: true, + legacyMemorySidecarPresent: false, + startupCheckpoint: CURRENT_OPENCLAW_VERSION, + }, + }[phase]; + expect(summary.startupCheckpoint).toBe(expectedPhaseContract.startupCheckpoint); + expect(summary.legacyMemoryMarker).toBe(LEGACY_MEMORY_MARKER); + expect(summary.legacyMemorySidecarPresent).toBe(expectedPhaseContract.legacyMemorySidecarPresent); + expect(summary.legacyMemorySidecarArchived).toBe( + expectedPhaseContract.legacyMemorySidecarArchived, + ); const versionToken = summary.version.match(/\b\d{4}\.\d{1,2}\.\d{1,2}\b/)?.[0]; expect(versionToken).toBe(phase === "legacy" ? OLD_OPENCLAW_VERSION : CURRENT_OPENCLAW_VERSION); return summary; @@ -431,11 +442,50 @@ async function assertOpenClawAgentSecretBoundary( ); expect(requests.some((request) => request.auth === "Bearer dummy")).toBe(true); expect(requests.some((request) => request.auth === "Bearer unused")).toBe(false); - expect( - requests.some((request) => request.auth?.includes("openshell:resolve:env:")), - ).toBe(false); + expect(requests.some((request) => request.auth?.includes("openshell:resolve:env:"))).toBe(false); +} + +async function captureLegacyOpenClawStateUpgradeProof( + host: HostCliClient, + fake: FakeOpenAiCompatibleServer, + artifacts: ArtifactSink, +): Promise { + await assertOpenClawAgentSecretBoundary(host, fake, "legacy"); + const legacyStateContract = await inspectOpenClawStateContract(host, "legacy"); + await artifacts.writeJson("openclaw-2026-6-state-contract.json", legacyStateContract); + return legacyStateContract; } +async function verifyUpgradedOpenClawStateUpgradeProof( + host: HostCliClient, + fake: FakeOpenAiCompatibleServer, + artifacts: ArtifactSink, + legacyStateContract: OpenClawStateContract | undefined, +): Promise { + expect(legacyStateContract).toBeDefined(); + const upgradedStateContract = await inspectOpenClawStateContract(host, "upgraded"); + expectOptionalStateReferencesPreserved(legacyStateContract!, upgradedStateContract); + await artifacts.writeJson("openclaw-2026-7-state-contract.json", upgradedStateContract); + await assertOpenClawAgentSecretBoundary(host, fake, "upgraded"); +} + +const captureOpenClawStateUpgradeProof: ( + host: HostCliClient, + fake: FakeOpenAiCompatibleServer, + artifacts: ArtifactSink, +) => Promise = OPENCLAW_STATE_UPGRADE_PROOF + ? captureLegacyOpenClawStateUpgradeProof + : () => Promise.resolve(undefined); + +const verifyOpenClawStateUpgradeProof: ( + host: HostCliClient, + fake: FakeOpenAiCompatibleServer, + artifacts: ArtifactSink, + legacyStateContract: OpenClawStateContract | undefined, +) => Promise = OPENCLAW_STATE_UPGRADE_PROOF + ? verifyUpgradedOpenClawStateUpgradeProof + : () => Promise.resolve(); + // The frozen release installers are the source of truth, but their embedded // Dockerfiles predate the fixture pins needed for a deterministic upgrade test. // Keep this adapter scoped to the frozen historical lanes and retire it with @@ -720,7 +770,11 @@ async function installOldNemoclawAndClaw( // v0.0.89 already contains the reviewed 2026.6.10 integrity/tarball pins. // Keep it byte-representative of the installed base instead of injecting // the legacy fixture adapter used by the established older lanes. - if (OLD_NEMOCLAW_REF !== "v0.0.89") patchOldInstallerFixture(oldInstaller); + const oldInstallerFixturePatches = + OLD_NEMOCLAW_REF === "v0.0.89" ? [] : [patchOldInstallerFixture]; + for (const patchInstaller of oldInstallerFixturePatches) { + patchInstaller(oldInstaller); + } const installEnv = liveEnv({ PATH: `${wrapperDir}:${process.env.PATH ?? "/usr/bin:/bin"}`, @@ -1162,12 +1216,7 @@ runLinuxOpenShellGatewayUpgrade( }); await installOldNemoclawAndClaw(host, artifacts, fake.baseUrl); - let legacyStateContract: OpenClawStateContract | undefined; - if (OPENCLAW_STATE_UPGRADE_PROOF) { - await assertOpenClawAgentSecretBoundary(host, fake, "legacy"); - legacyStateContract = await inspectOpenClawStateContract(host, "legacy"); - await artifacts.writeJson("openclaw-2026-6-state-contract.json", legacyStateContract); - } + const legacyStateContract = await captureOpenClawStateUpgradeProof(host, fake, artifacts); const hiddenOldOpenShellDir = OLD_NEMOCLAW_REF === "v0.0.55" ? await stageOldOpenShellInUserLocalBin(host) : undefined; const survivorPid = await startSurvivorAgentInExistingClaw(host); @@ -1179,12 +1228,7 @@ runLinuxOpenShellGatewayUpgrade( hiddenOldOpenShellDir, ); await assertSurvivorSandboxAfterUpgrade(host); - if (OPENCLAW_STATE_UPGRADE_PROOF) { - const upgradedStateContract = await inspectOpenClawStateContract(host, "upgraded"); - expectOptionalStateReferencesPreserved(legacyStateContract!, upgradedStateContract); - await artifacts.writeJson("openclaw-2026-7-state-contract.json", upgradedStateContract); - await assertOpenClawAgentSecretBoundary(host, fake, "upgraded"); - } + await verifyOpenClawStateUpgradeProof(host, fake, artifacts, legacyStateContract); }, ); diff --git a/test/nemoclaw-start-shared-state-topology.test.ts b/test/nemoclaw-start-shared-state-topology.test.ts index 673257df558..6b41369b166 100644 --- a/test/nemoclaw-start-shared-state-topology.test.ts +++ b/test/nemoclaw-start-shared-state-topology.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { type SpawnSyncReturns, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -11,9 +11,10 @@ const START_SCRIPT = path.join(process.cwd(), "scripts", "nemoclaw-start.sh"); function sourceBlock(source: string, startMarker: string, endMarker: string): string { const start = source.indexOf(startMarker); const end = source.indexOf(endMarker, start); - if (start === -1 || end === -1 || end <= start) { - throw new Error(`Expected ${startMarker} before ${endMarker} in nemoclaw-start.sh`); - } + expect(start, `Expected ${startMarker} in nemoclaw-start.sh`).toBeGreaterThanOrEqual(0); + expect(end, `Expected ${endMarker} after ${startMarker} in nemoclaw-start.sh`).toBeGreaterThan( + start, + ); return source.slice(start, end); } From 94026ba8cd9029d8c021f7726cf48c345bbd847d Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 08:12:28 -0700 Subject: [PATCH 13/24] style(test): apply repository formatting Signed-off-by: Aaron Erickson --- ...law-shared-state-permissions-patch.test.ts | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/test/openclaw-shared-state-permissions-patch.test.ts b/test/openclaw-shared-state-permissions-patch.test.ts index 5f375905e28..eeac52abac0 100644 --- a/test/openclaw-shared-state-permissions-patch.test.ts +++ b/test/openclaw-shared-state-permissions-patch.test.ts @@ -502,31 +502,31 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { } }); - it.each(["1", "sandbox-name"])( - "retains owner-only per-agent database modes in same-UID OpenShell %s", - async (openShellMarker) => { - const fixture = makeFixture(); - try { - patchOpenClawSharedStatePermissions(fixture.dist); - const runtime = await importAgentFixture(fixture.agentFiles[0]); - const agentDir = path.join(fixture.root, "openshell-agent-state"); - const database = path.join(agentDir, "main.sqlite"); - const options = { - agentId: "main", - env: { OPENCLAW_AGENT_DIR: agentDir, OPENSHELL_SANDBOX: openShellMarker }, - }; - - runtime.ensureOpenClawAgentDatabasePermissions(database, options); - fs.writeFileSync(database, ""); - runtime.ensureOpenClawAgentDatabasePermissions(database, options); - - expect(mode(agentDir)).toBe(0o700); - expect(mode(database)).toBe(0o600); - } finally { - fs.rmSync(fixture.root, { recursive: true, force: true }); - } - }, - ); + it.each([ + "1", + "sandbox-name", + ])("retains owner-only per-agent database modes in same-UID OpenShell %s", async (openShellMarker) => { + const fixture = makeFixture(); + try { + patchOpenClawSharedStatePermissions(fixture.dist); + const runtime = await importAgentFixture(fixture.agentFiles[0]); + const agentDir = path.join(fixture.root, "openshell-agent-state"); + const database = path.join(agentDir, "main.sqlite"); + const options = { + agentId: "main", + env: { OPENCLAW_AGENT_DIR: agentDir, OPENSHELL_SANDBOX: openShellMarker }, + }; + + runtime.ensureOpenClawAgentDatabasePermissions(database, options); + fs.writeFileSync(database, ""); + runtime.ensureOpenClawAgentDatabasePermissions(database, options); + + expect(mode(agentDir)).toBe(0o700); + expect(mode(database)).toBe(0o600); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); it("retains owner-only secret-file modes under the NemoClaw marker", async () => { const fixture = makeFixture(); @@ -683,7 +683,10 @@ describe("OpenClaw SQLite state permission compatibility patch (#7280)", () => { } }); - it.each(["1", "sandbox-name"])("retains the upstream generated-models mode in same-UID OpenShell %s", async (openShellMarker) => { + it.each([ + "1", + "sandbox-name", + ])("retains the upstream generated-models mode in same-UID OpenShell %s", async (openShellMarker) => { const fixture = makeFixture(); const previousShared = process.env.NEMOCLAW_OPENCLAW_SHARED_STATE; const previousOpenShell = process.env.OPENSHELL_SANDBOX; From b97aa2f040796711801e4b299a16f1960b23dd16 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 08:15:24 -0700 Subject: [PATCH 14/24] test(e2e): honor redacted auth records Signed-off-by: Aaron Erickson --- test/e2e/live/openshell-gateway-upgrade.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index afe39bd46e5..7a8343389e2 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -440,9 +440,13 @@ async function assertOpenClawAgentSecretBoundary( expect(requests.length, `${phase} agent turn must reach the compatible endpoint`).toBeGreaterThan( 0, ); - expect(requests.some((request) => request.auth === "Bearer dummy")).toBe(true); - expect(requests.some((request) => request.auth === "Bearer unused")).toBe(false); - expect(requests.some((request) => request.auth?.includes("openshell:resolve:env:"))).toBe(false); + // The fake endpoint deliberately records only the validated auth result, not + // the bearer value. With requireAuth enabled, "ok" means the request carried + // the exact gateway-held `dummy` credential; `unused`, a placeholder, or a + // missing header would receive 401 and could not complete this agent turn. + expect( + requests.every((request) => request.auth === "ok" && request.authorizationSent === true), + ).toBe(true); } async function captureLegacyOpenClawStateUpgradeProof( From 0341921519dd4cf3cfbf3906dbbaab3ace5f0423 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 08:27:39 -0700 Subject: [PATCH 15/24] test(e2e): use real legacy OpenClaw state Signed-off-by: Aaron Erickson --- .../openclaw-2026.7.1-dependency-review.md | 10 ++- .../live/openshell-gateway-upgrade.test.ts | 73 ++++++++++++------- 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 00fc52f7b37..556491e0f83 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -185,10 +185,12 @@ follows: Installed-base coverage is the `v0.0.89-x86_64` row in the `openshell-gateway-upgrade` E2E matrix. It installs the immutable v0.0.89 release with OpenClaw `2026.6.10`, seeds its legacy Memory Core SQLite and -update-check state, then upgrades through the current installer. The row proves -the `2026.7.1` startup checkpoint and per-agent database migration complete -with intact SQLite state, and that the restored `apiKey: "unused"` config -still receives its gateway-held credential only at the OpenShell boundary. +update-check state plus a marker in the global database initialized by the +legacy gateway, then upgrades through the current installer. The row proves +the global database survives intact, the `2026.7.1` startup checkpoint and +per-agent database migration complete, and the restored `apiKey: "unused"` +config still receives its gateway-held credential only at the OpenShell +boundary. This custom route supplies `COMPATIBLE_API_KEY`, so the frozen v0.0.89 runtime intentionally creates no NVIDIA auth-profile key reference; the E2E preserves any references that do exist without inventing one for this route. diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index 7a8343389e2..284d55b903a 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -86,7 +86,7 @@ const SURVIVOR_SANDBOX = .join("-"); const SURVIVOR_MARKER = `gateway-upgrade-survivor-${Date.now()}`; const SURVIVOR_MARKER_PATH = "/sandbox/.openclaw/workspace/nemoclaw-gateway-upgrade-marker"; -const INSTALLED_AGENT_DB_MARKER = `openclaw-2026-6-agent-db-${Date.now()}`; +const INSTALLED_STATE_DB_MARKER = `openclaw-2026-6-state-db-${Date.now()}`; const LEGACY_MEMORY_MARKER = `openclaw-2026-6-memory-${Date.now()}`; const LEGACY_MEMORY_SIDECAR = "/sandbox/.openclaw/memory/main.sqlite"; const OPENCLAW_GLOBAL_STATE_DB = "/sandbox/.openclaw/state/openclaw.sqlite"; @@ -208,10 +208,11 @@ async function bash( } interface OpenClawStateContract { - agentDbIntegrity: string; + agentDbIntegrity: string | null; + agentDbPresent: boolean; apiKey: unknown; globalDbIntegrity: string; - installedAgentDbMarker: string | null; + installedStateDbMarker: string | null; keyRefIds: string[]; legacyMemoryMarker: string | null; legacyMemorySidecarArchived: boolean; @@ -259,7 +260,7 @@ const configPath = "/sandbox/.openclaw/openclaw.json"; const authPath = "/sandbox/.openclaw/agents/main/agent/auth-profiles.json"; const globalDbPath = ${JSON.stringify(OPENCLAW_GLOBAL_STATE_DB)}; const agentDbPath = ${JSON.stringify(OPENCLAW_MAIN_AGENT_DB)}; -const installedAgentDbMarkerValue = ${JSON.stringify(INSTALLED_AGENT_DB_MARKER)}; +const installedStateDbMarkerValue = ${JSON.stringify(INSTALLED_STATE_DB_MARKER)}; const legacyMemoryPath = ${JSON.stringify(LEGACY_MEMORY_SIDECAR)}; const legacyMemoryMarkerValue = ${JSON.stringify(LEGACY_MEMORY_MARKER)}; const updateCheckPath = ${JSON.stringify(LEGACY_UPDATE_CHECK_PATH)}; @@ -273,7 +274,30 @@ if (${JSON.stringify(seedLegacyUpdateCheck)}) { lastAvailableVersion: "2026.7.1", }) + "\n", { mode: 0o600 }); } -const globalDb = new DatabaseSync(globalDbPath, { readOnly: true }); +const globalDb = new DatabaseSync(globalDbPath, { + readOnly: !${JSON.stringify(seedLegacyUpdateCheck)}, +}); +globalDb.exec("PRAGMA busy_timeout = 5000"); +if (${JSON.stringify(seedLegacyUpdateCheck)}) { + // Seed the database that the real 6.10 gateway initialized. Its agent DB is + // not created for this custom-provider route until 7.1 migrates the Memory + // Core sidecar, so opening that path here would manufacture upgrade state. + const primaryStateSchema = globalDb + .prepare("SELECT role, schema_version AS schemaVersion FROM schema_meta WHERE meta_key = ?") + .get("primary"); + if (!primaryStateSchema) throw new Error("legacy OpenClaw global database has no primary schema metadata"); + const now = Date.now(); + globalDb + .prepare("INSERT OR REPLACE INTO schema_meta (meta_key, role, schema_version, agent_id, app_version, created_at, updated_at) VALUES (?, ?, ?, NULL, ?, ?, ?)") + .run( + "nemoclaw-e2e-state-upgrade", + primaryStateSchema.role, + primaryStateSchema.schemaVersion, + installedStateDbMarkerValue, + now, + now, + ); +} const globalDbIntegrity = globalDb.prepare("PRAGMA integrity_check").get().integrity_check; const hasSchemaMeta = globalDb .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") @@ -283,24 +307,12 @@ const startupCheckpoint = hasSchemaMeta .prepare("SELECT app_version AS appVersion FROM schema_meta WHERE meta_key = ?") .get("startup-migrations")?.appVersion ?? null : null; +const installedStateDbMarker = globalDb + .prepare("SELECT app_version AS appVersion FROM schema_meta WHERE meta_key = ?") + .get("nemoclaw-e2e-state-upgrade")?.appVersion ?? null; globalDb.close(); -const agentDb = new DatabaseSync(agentDbPath, { - readOnly: !${JSON.stringify(seedLegacyUpdateCheck)}, -}); -agentDb.exec("PRAGMA busy_timeout = 5000"); -if (${JSON.stringify(seedLegacyUpdateCheck)}) { - // cache_entries is present with this exact shape in both published versions; - // a non-expiring private scope proves the real 6.10 agent DB was restored - // without being coupled to Memory Core's normal reindex cleanup. - agentDb.prepare("INSERT OR REPLACE INTO cache_entries (scope, key, value_json, blob, expires_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?)") - .run("nemoclaw-e2e-state-upgrade", "installed-agent-db-marker", JSON.stringify(installedAgentDbMarkerValue), 1); -} -const agentDbIntegrity = agentDb.prepare("PRAGMA integrity_check").get().integrity_check; -const installedAgentDbMarkerJson = agentDb - .prepare("SELECT value_json AS valueJson FROM cache_entries WHERE scope = ? AND key = ?") - .get("nemoclaw-e2e-state-upgrade", "installed-agent-db-marker")?.valueJson; -const installedAgentDbMarker = - typeof installedAgentDbMarkerJson === "string" ? JSON.parse(installedAgentDbMarkerJson) : null; +const agentDbPresent = fs.existsSync(agentDbPath); +let agentDbIntegrity = null; let legacyMemoryMarker = null; if (${JSON.stringify(seedLegacyUpdateCheck)}) { fs.mkdirSync(path.dirname(legacyMemoryPath), { recursive: true }); @@ -320,11 +332,14 @@ if (${JSON.stringify(seedLegacyUpdateCheck)}) { fs.chmodSync(legacyMemoryPath, 0o600); legacyMemoryMarker = legacyMemoryMarkerValue; } else { + const agentDb = new DatabaseSync(agentDbPath, { readOnly: true }); + agentDb.exec("PRAGMA busy_timeout = 5000"); + agentDbIntegrity = agentDb.prepare("PRAGMA integrity_check").get().integrity_check; legacyMemoryMarker = agentDb .prepare("SELECT text FROM memory_index_chunks WHERE id = ?") .get("nemoclaw-e2e-legacy-memory")?.text ?? null; + agentDb.close(); } -agentDb.close(); const keyRefIds = []; function collectKeyRefs(value) { if (!value || typeof value !== "object") return; @@ -338,9 +353,10 @@ collectKeyRefs(auth); const envEntries = Object.entries(process.env); console.log(JSON.stringify({ agentDbIntegrity, + agentDbPresent, apiKey: config.models?.providers?.inference?.apiKey, globalDbIntegrity, - installedAgentDbMarker, + installedStateDbMarker, keyRefIds: [...new Set(keyRefIds)].sort(), legacyMemoryMarker, legacyMemorySidecarArchived: fs.existsSync(legacyMemoryPath + ".migrated"), @@ -373,20 +389,25 @@ console.log(JSON.stringify({ expect(summary.literalSecretInState).toBe(false); expect(summary.updateCheckPresent).toBe(seedLegacyUpdateCheck); expect(summary.globalDbIntegrity).toBe("ok"); - expect(summary.agentDbIntegrity).toBe("ok"); - expect(summary.installedAgentDbMarker).toBe(INSTALLED_AGENT_DB_MARKER); + expect(summary.installedStateDbMarker).toBe(INSTALLED_STATE_DB_MARKER); const expectedPhaseContract = { legacy: { + agentDbIntegrity: null, + agentDbPresent: false, legacyMemorySidecarArchived: false, legacyMemorySidecarPresent: true, startupCheckpoint: null, }, upgraded: { + agentDbIntegrity: "ok", + agentDbPresent: true, legacyMemorySidecarArchived: true, legacyMemorySidecarPresent: false, startupCheckpoint: CURRENT_OPENCLAW_VERSION, }, }[phase]; + expect(summary.agentDbIntegrity).toBe(expectedPhaseContract.agentDbIntegrity); + expect(summary.agentDbPresent).toBe(expectedPhaseContract.agentDbPresent); expect(summary.startupCheckpoint).toBe(expectedPhaseContract.startupCheckpoint); expect(summary.legacyMemoryMarker).toBe(LEGACY_MEMORY_MARKER); expect(summary.legacyMemorySidecarPresent).toBe(expectedPhaseContract.legacyMemorySidecarPresent); From 26a9dbc4e97ca6e6da59ebbc2ea081b03a0af611 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 08:30:29 -0700 Subject: [PATCH 16/24] test: include installed-base upgrade signal shard Signed-off-by: Aaron Erickson --- test/pr-e2e-gate-signal-shards.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/pr-e2e-gate-signal-shards.test.ts b/test/pr-e2e-gate-signal-shards.test.ts index 802a3e6d47e..c42c2b76fa5 100644 --- a/test/pr-e2e-gate-signal-shards.test.ts +++ b/test/pr-e2e-gate-signal-shards.test.ts @@ -41,6 +41,7 @@ describe("PR E2E signal shard policy", () => { "v0-0-55-x86-64", "v0-0-55-aarch64", "v0-0-74-x86-64", + "v0-0-89-x86-64", ], [DCODE_TARGET]: ["default"], }); From 399d796a54046fd246dc3d3f7bf5af677884ca89 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 08:35:33 -0700 Subject: [PATCH 17/24] test(e2e): materialize legacy OpenClaw database Signed-off-by: Aaron Erickson --- .../openclaw-2026.7.1-dependency-review.md | 11 +- .../live/openshell-gateway-upgrade.test.ts | 101 +++++++++--------- 2 files changed, 58 insertions(+), 54 deletions(-) diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 556491e0f83..19a3acf5ff8 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -185,12 +185,11 @@ follows: Installed-base coverage is the `v0.0.89-x86_64` row in the `openshell-gateway-upgrade` E2E matrix. It installs the immutable v0.0.89 release with OpenClaw `2026.6.10`, seeds its legacy Memory Core SQLite and -update-check state plus a marker in the global database initialized by the -legacy gateway, then upgrades through the current installer. The row proves -the global database survives intact, the `2026.7.1` startup checkpoint and -per-agent database migration complete, and the restored `apiKey: "unused"` -config still receives its gateway-held credential only at the OpenShell -boundary. +update-check state plus a durable marker in the per-agent database materialized +by the legacy CLI, then upgrades through the current installer. The row proves +the global and per-agent databases survive intact, the legacy sidecar migration +and `2026.7.1` startup checkpoint complete, and the restored `apiKey: "unused"` +config still receives its gateway-held credential only at the OpenShell boundary. This custom route supplies `COMPATIBLE_API_KEY`, so the frozen v0.0.89 runtime intentionally creates no NVIDIA auth-profile key reference; the E2E preserves any references that do exist without inventing one for this route. diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index 284d55b903a..c4ffe09f5b8 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -86,7 +86,7 @@ const SURVIVOR_SANDBOX = .join("-"); const SURVIVOR_MARKER = `gateway-upgrade-survivor-${Date.now()}`; const SURVIVOR_MARKER_PATH = "/sandbox/.openclaw/workspace/nemoclaw-gateway-upgrade-marker"; -const INSTALLED_STATE_DB_MARKER = `openclaw-2026-6-state-db-${Date.now()}`; +const INSTALLED_AGENT_DB_MARKER = `openclaw-2026-6-agent-db-${Date.now()}`; const LEGACY_MEMORY_MARKER = `openclaw-2026-6-memory-${Date.now()}`; const LEGACY_MEMORY_SIDECAR = "/sandbox/.openclaw/memory/main.sqlite"; const OPENCLAW_GLOBAL_STATE_DB = "/sandbox/.openclaw/state/openclaw.sqlite"; @@ -208,11 +208,11 @@ async function bash( } interface OpenClawStateContract { - agentDbIntegrity: string | null; - agentDbPresent: boolean; + agentDbIntegrity: string; apiKey: unknown; globalDbIntegrity: string; - installedStateDbMarker: string | null; + globalDbPrimaryCreatedAt: number; + installedAgentDbMarker: string | null; keyRefIds: string[]; legacyMemoryMarker: string | null; legacyMemorySidecarArchived: boolean; @@ -260,7 +260,7 @@ const configPath = "/sandbox/.openclaw/openclaw.json"; const authPath = "/sandbox/.openclaw/agents/main/agent/auth-profiles.json"; const globalDbPath = ${JSON.stringify(OPENCLAW_GLOBAL_STATE_DB)}; const agentDbPath = ${JSON.stringify(OPENCLAW_MAIN_AGENT_DB)}; -const installedStateDbMarkerValue = ${JSON.stringify(INSTALLED_STATE_DB_MARKER)}; +const installedAgentDbMarkerValue = ${JSON.stringify(INSTALLED_AGENT_DB_MARKER)}; const legacyMemoryPath = ${JSON.stringify(LEGACY_MEMORY_SIDECAR)}; const legacyMemoryMarkerValue = ${JSON.stringify(LEGACY_MEMORY_MARKER)}; const updateCheckPath = ${JSON.stringify(LEGACY_UPDATE_CHECK_PATH)}; @@ -274,45 +274,57 @@ if (${JSON.stringify(seedLegacyUpdateCheck)}) { lastAvailableVersion: "2026.7.1", }) + "\n", { mode: 0o600 }); } -const globalDb = new DatabaseSync(globalDbPath, { - readOnly: !${JSON.stringify(seedLegacyUpdateCheck)}, -}); +const globalDb = new DatabaseSync(globalDbPath, { readOnly: true }); globalDb.exec("PRAGMA busy_timeout = 5000"); -if (${JSON.stringify(seedLegacyUpdateCheck)}) { - // Seed the database that the real 6.10 gateway initialized. Its agent DB is - // not created for this custom-provider route until 7.1 migrates the Memory - // Core sidecar, so opening that path here would manufacture upgrade state. - const primaryStateSchema = globalDb - .prepare("SELECT role, schema_version AS schemaVersion FROM schema_meta WHERE meta_key = ?") - .get("primary"); - if (!primaryStateSchema) throw new Error("legacy OpenClaw global database has no primary schema metadata"); - const now = Date.now(); - globalDb - .prepare("INSERT OR REPLACE INTO schema_meta (meta_key, role, schema_version, agent_id, app_version, created_at, updated_at) VALUES (?, ?, ?, NULL, ?, ?, ?)") - .run( - "nemoclaw-e2e-state-upgrade", - primaryStateSchema.role, - primaryStateSchema.schemaVersion, - installedStateDbMarkerValue, - now, - now, - ); -} const globalDbIntegrity = globalDb.prepare("PRAGMA integrity_check").get().integrity_check; const hasSchemaMeta = globalDb .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") .get("schema_meta"); +const globalDbPrimaryCreatedAt = globalDb + .prepare("SELECT created_at AS createdAt FROM schema_meta WHERE meta_key = ?") + .get("primary")?.createdAt; +if (typeof globalDbPrimaryCreatedAt !== "number") { + throw new Error("OpenClaw global database has no primary schema creation timestamp"); +} const startupCheckpoint = hasSchemaMeta ? globalDb .prepare("SELECT app_version AS appVersion FROM schema_meta WHERE meta_key = ?") .get("startup-migrations")?.appVersion ?? null : null; -const installedStateDbMarker = globalDb - .prepare("SELECT app_version AS appVersion FROM schema_meta WHERE meta_key = ?") - .get("nemoclaw-e2e-state-upgrade")?.appVersion ?? null; globalDb.close(); -const agentDbPresent = fs.existsSync(agentDbPath); -let agentDbIntegrity = null; +if (${JSON.stringify(seedLegacyUpdateCheck)}) { + // A normal agent turn need not touch agent-local SQLite. Materialize the + // real 6.10 schema through its public, offline memory command before probing + // it; a writable DatabaseSync open on a missing path would create an empty + // file and manufacture state that no legacy OpenClaw process initialized. + execFileSync("openclaw", ["memory", "status", "--json", "--agent", "main"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} +if (!fs.existsSync(agentDbPath)) { + throw new Error("OpenClaw agent database was not materialized at " + agentDbPath); +} +const agentDb = new DatabaseSync(agentDbPath, { + readOnly: !${JSON.stringify(seedLegacyUpdateCheck)}, +}); +agentDb.exec("PRAGMA busy_timeout = 5000"); +if (${JSON.stringify(seedLegacyUpdateCheck)}) { + agentDb + .prepare("INSERT OR REPLACE INTO cache_entries (scope, key, value_json, blob, expires_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?)") + .run( + "nemoclaw-e2e-state-upgrade", + "installed-agent-db-marker", + JSON.stringify(installedAgentDbMarkerValue), + Date.now(), + ); +} +const agentDbIntegrity = agentDb.prepare("PRAGMA integrity_check").get().integrity_check; +const installedAgentDbMarkerJson = agentDb + .prepare("SELECT value_json AS valueJson FROM cache_entries WHERE scope = ? AND key = ?") + .get("nemoclaw-e2e-state-upgrade", "installed-agent-db-marker")?.valueJson; +const installedAgentDbMarker = + typeof installedAgentDbMarkerJson === "string" ? JSON.parse(installedAgentDbMarkerJson) : null; let legacyMemoryMarker = null; if (${JSON.stringify(seedLegacyUpdateCheck)}) { fs.mkdirSync(path.dirname(legacyMemoryPath), { recursive: true }); @@ -332,14 +344,11 @@ if (${JSON.stringify(seedLegacyUpdateCheck)}) { fs.chmodSync(legacyMemoryPath, 0o600); legacyMemoryMarker = legacyMemoryMarkerValue; } else { - const agentDb = new DatabaseSync(agentDbPath, { readOnly: true }); - agentDb.exec("PRAGMA busy_timeout = 5000"); - agentDbIntegrity = agentDb.prepare("PRAGMA integrity_check").get().integrity_check; legacyMemoryMarker = agentDb .prepare("SELECT text FROM memory_index_chunks WHERE id = ?") .get("nemoclaw-e2e-legacy-memory")?.text ?? null; - agentDb.close(); } +agentDb.close(); const keyRefIds = []; function collectKeyRefs(value) { if (!value || typeof value !== "object") return; @@ -353,10 +362,10 @@ collectKeyRefs(auth); const envEntries = Object.entries(process.env); console.log(JSON.stringify({ agentDbIntegrity, - agentDbPresent, apiKey: config.models?.providers?.inference?.apiKey, globalDbIntegrity, - installedStateDbMarker, + globalDbPrimaryCreatedAt, + installedAgentDbMarker, keyRefIds: [...new Set(keyRefIds)].sort(), legacyMemoryMarker, legacyMemorySidecarArchived: fs.existsSync(legacyMemoryPath + ".migrated"), @@ -389,25 +398,20 @@ console.log(JSON.stringify({ expect(summary.literalSecretInState).toBe(false); expect(summary.updateCheckPresent).toBe(seedLegacyUpdateCheck); expect(summary.globalDbIntegrity).toBe("ok"); - expect(summary.installedStateDbMarker).toBe(INSTALLED_STATE_DB_MARKER); + expect(summary.agentDbIntegrity).toBe("ok"); + expect(summary.installedAgentDbMarker).toBe(INSTALLED_AGENT_DB_MARKER); const expectedPhaseContract = { legacy: { - agentDbIntegrity: null, - agentDbPresent: false, legacyMemorySidecarArchived: false, legacyMemorySidecarPresent: true, startupCheckpoint: null, }, upgraded: { - agentDbIntegrity: "ok", - agentDbPresent: true, legacyMemorySidecarArchived: true, legacyMemorySidecarPresent: false, startupCheckpoint: CURRENT_OPENCLAW_VERSION, }, }[phase]; - expect(summary.agentDbIntegrity).toBe(expectedPhaseContract.agentDbIntegrity); - expect(summary.agentDbPresent).toBe(expectedPhaseContract.agentDbPresent); expect(summary.startupCheckpoint).toBe(expectedPhaseContract.startupCheckpoint); expect(summary.legacyMemoryMarker).toBe(LEGACY_MEMORY_MARKER); expect(summary.legacyMemorySidecarPresent).toBe(expectedPhaseContract.legacyMemorySidecarPresent); @@ -419,10 +423,11 @@ console.log(JSON.stringify({ return summary; } -function expectOptionalStateReferencesPreserved( +function expectStatePreservedAcrossUpgrade( legacy: OpenClawStateContract, upgraded: OpenClawStateContract, ): void { + expect(upgraded.globalDbPrimaryCreatedAt).toBe(legacy.globalDbPrimaryCreatedAt); // This custom-provider fixture sets COMPATIBLE_API_KEY, not // NVIDIA_INFERENCE_API_KEY, so v0.0.89 intentionally does not create the // NVIDIA auth-profile keyRef. Preserve any references the frozen runtime @@ -489,7 +494,7 @@ async function verifyUpgradedOpenClawStateUpgradeProof( ): Promise { expect(legacyStateContract).toBeDefined(); const upgradedStateContract = await inspectOpenClawStateContract(host, "upgraded"); - expectOptionalStateReferencesPreserved(legacyStateContract!, upgradedStateContract); + expectStatePreservedAcrossUpgrade(legacyStateContract!, upgradedStateContract); await artifacts.writeJson("openclaw-2026-7-state-contract.json", upgradedStateContract); await assertOpenClawAgentSecretBoundary(host, fake, "upgraded"); } From e1ad3a0fbaca80715fd53ab878e406f2b63449c3 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 08:51:21 -0700 Subject: [PATCH 18/24] test(e2e): assert durable OpenClaw state only Signed-off-by: Aaron Erickson --- docs/security/openclaw-2026.7.1-dependency-review.md | 7 ++++--- test/e2e/live/openshell-gateway-upgrade.test.ts | 9 --------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 19a3acf5ff8..b71df5b1be2 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -187,9 +187,10 @@ Installed-base coverage is the `v0.0.89-x86_64` row in the release with OpenClaw `2026.6.10`, seeds its legacy Memory Core SQLite and update-check state plus a durable marker in the per-agent database materialized by the legacy CLI, then upgrades through the current installer. The row proves -the global and per-agent databases survive intact, the legacy sidecar migration -and `2026.7.1` startup checkpoint complete, and the restored `apiKey: "unused"` -config still receives its gateway-held credential only at the OpenShell boundary. +the per-agent database survives intact, the global database remains healthy, +the legacy sidecar migration and `2026.7.1` startup checkpoint complete, and +the restored `apiKey: "unused"` config still receives its gateway-held +credential only at the OpenShell boundary. This custom route supplies `COMPATIBLE_API_KEY`, so the frozen v0.0.89 runtime intentionally creates no NVIDIA auth-profile key reference; the E2E preserves any references that do exist without inventing one for this route. diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index c4ffe09f5b8..b73e685a8b3 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -211,7 +211,6 @@ interface OpenClawStateContract { agentDbIntegrity: string; apiKey: unknown; globalDbIntegrity: string; - globalDbPrimaryCreatedAt: number; installedAgentDbMarker: string | null; keyRefIds: string[]; legacyMemoryMarker: string | null; @@ -280,12 +279,6 @@ const globalDbIntegrity = globalDb.prepare("PRAGMA integrity_check").get().integ const hasSchemaMeta = globalDb .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") .get("schema_meta"); -const globalDbPrimaryCreatedAt = globalDb - .prepare("SELECT created_at AS createdAt FROM schema_meta WHERE meta_key = ?") - .get("primary")?.createdAt; -if (typeof globalDbPrimaryCreatedAt !== "number") { - throw new Error("OpenClaw global database has no primary schema creation timestamp"); -} const startupCheckpoint = hasSchemaMeta ? globalDb .prepare("SELECT app_version AS appVersion FROM schema_meta WHERE meta_key = ?") @@ -364,7 +357,6 @@ console.log(JSON.stringify({ agentDbIntegrity, apiKey: config.models?.providers?.inference?.apiKey, globalDbIntegrity, - globalDbPrimaryCreatedAt, installedAgentDbMarker, keyRefIds: [...new Set(keyRefIds)].sort(), legacyMemoryMarker, @@ -427,7 +419,6 @@ function expectStatePreservedAcrossUpgrade( legacy: OpenClawStateContract, upgraded: OpenClawStateContract, ): void { - expect(upgraded.globalDbPrimaryCreatedAt).toBe(legacy.globalDbPrimaryCreatedAt); // This custom-provider fixture sets COMPATIBLE_API_KEY, not // NVIDIA_INFERENCE_API_KEY, so v0.0.89 intentionally does not create the // NVIDIA auth-profile keyRef. Preserve any references the frozen runtime From 8677985f21f9ef963490bcd5b7102af873961c5b Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 09:00:01 -0700 Subject: [PATCH 19/24] test: prove installed credential reuse on upgrade Signed-off-by: Aaron Erickson --- .../security/openclaw-2026.7.1-dependency-review.md | 8 +++++--- test/e2e/live/openshell-gateway-upgrade.test.ts | 13 +++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index b71df5b1be2..17884af4170 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -191,9 +191,11 @@ the per-agent database survives intact, the global database remains healthy, the legacy sidecar migration and `2026.7.1` startup checkpoint complete, and the restored `apiKey: "unused"` config still receives its gateway-held credential only at the OpenShell boundary. -This custom route supplies `COMPATIBLE_API_KEY`, so the frozen v0.0.89 -runtime intentionally creates no NVIDIA auth-profile key reference; the E2E -preserves any references that do exist without inventing one for this route. +This custom route supplies `COMPATIBLE_API_KEY` only to the frozen v0.0.89 +install, then deliberately withholds it from the current installer so the +post-upgrade turn proves the existing gateway-held credential was reused. The +frozen runtime intentionally creates no NVIDIA auth-profile key reference; the +E2E preserves any references that do exist without inventing one for this route. During image assembly, the shared-state repair rejects symbolic links, non-regular entries, and multiply linked files before it changes the ownership diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index b73e685a8b3..11790a0ea87 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -419,6 +419,8 @@ function expectStatePreservedAcrossUpgrade( legacy: OpenClawStateContract, upgraded: OpenClawStateContract, ): void { + expect(upgraded.placeholderEnvKeys).toContain("COMPATIBLE_API_KEY"); + // This custom-provider fixture sets COMPATIBLE_API_KEY, not // NVIDIA_INFERENCE_API_KEY, so v0.0.89 intentionally does not create the // NVIDIA auth-profile keyRef. Preserve any references the frozen runtime @@ -1006,8 +1008,11 @@ async function installCurrentNemoclawUpgrade( NEMOCLAW_DASHBOARD_PORT: "", CHAT_UI_URL: "", }); + const credentialScopedCurrentEnv = OPENCLAW_STATE_UPGRADE_PROOF + ? withoutEnvKeys(baseCurrentEnv, ["COMPATIBLE_API_KEY"]) + : baseCurrentEnv; const currentEnv = exerciseOrdinaryUpgrade - ? withoutEnvKeys(baseCurrentEnv, [ + ? withoutEnvKeys(credentialScopedCurrentEnv, [ "ACCEPT_THIRD_PARTY_SOFTWARE", "NON_INTERACTIVE", "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE", @@ -1016,10 +1021,14 @@ async function installCurrentNemoclawUpgrade( "NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE", ]) : { - ...baseCurrentEnv, + ...credentialScopedCurrentEnv, NEMOCLAW_ACCEPT_EXPERIMENTAL_OPENSHELL_UPGRADE: "1", NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE: JSON.stringify([SURVIVOR_SANDBOX]), }; + expect( + currentEnv.COMPATIBLE_API_KEY, + "installed-base upgrade must not re-seed the gateway credential", + ).toBe(OPENCLAW_STATE_UPGRADE_PROOF ? undefined : "dummy"); const redactionValues = [process.env.GITHUB_TOKEN ?? ""].filter(Boolean); await runInstallerPayload( host, From aab8d649996fd4141da789ccf8091988d6665f95 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 11:59:02 -0700 Subject: [PATCH 20/24] chore(ci): refresh gate after runner cancellation Signed-off-by: Aaron Erickson From ad4435a7556bbcc64577d3cea6d5d694501545e5 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 12:35:53 -0700 Subject: [PATCH 21/24] fix(security): remediate mcporter Hono advisory Signed-off-by: Aaron Erickson --- agents/openclaw/dependency-review.md | 5 +- .../mcporter-runtime/package-lock.json | 8 +-- agents/openclaw/mcporter-runtime/package.json | 3 + .../openclaw-2026.6.10-dependency-review.md | 8 ++- .../openclaw-2026.7.1-dependency-review.md | 14 ++++- .../live/openshell-gateway-upgrade-helpers.ts | 45 +++++++++++++++ .../live/openshell-gateway-upgrade.test.ts | 26 +++++++-- ...-gateway-upgrade-workflow-boundary.test.ts | 57 +++++++++++++++++++ test/mcporter-supply-chain.test.ts | 20 ++++++- test/openclaw-dependency-review.test.ts | 5 +- 10 files changed, 174 insertions(+), 17 deletions(-) diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index a7ff15c252f..1cbd2de3d75 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -15,9 +15,10 @@ Update it and `agents/openclaw/mcporter-runtime/package*.json` together whenever - Registry metadata independently queried from npm: 2026-06-30. - Locked graph: `agents/openclaw/mcporter-runtime/package-lock.json` (npm lockfile version 3). - Lock regeneration command: `npm --prefix agents/openclaw/mcporter-runtime install --package-lock-only --ignore-scripts --omit=dev` -- Advisory command: `npm --prefix agents/openclaw/mcporter-runtime ci --ignore-scripts --omit=dev && npm --prefix agents/openclaw/mcporter-runtime audit --omit=dev && npm --prefix agents/openclaw/mcporter-runtime audit signatures` -- Advisory review date: 2026-06-30. +- Advisory command: `npm --prefix agents/openclaw/mcporter-runtime ci --ignore-scripts --omit=dev && npm --prefix agents/openclaw/mcporter-runtime audit --omit=dev --audit-level=low && npm --prefix agents/openclaw/mcporter-runtime audit signatures` +- Advisory review date: 2026-07-21. - Advisory result: `0` known vulnerabilities across the resolved production dependency graph; npm verified registry signatures for all `120` resolved packages and attestations for `12` packages. +- `GHSA-frvp-7c67-39w9` remediation: the locked `@modelcontextprotocol/sdk@1.29.0` still requests vulnerable `@hono/node-server@^1.19.9`, while the first patched release is `2.0.5`. The runtime manifest therefore pins an exact npm override to `2.0.5`. Node `>=20` remains inside the image's Node `>=22.19.0` contract; real ESM and CommonJS Streamable HTTP transport construction/start/close probes plus `mcporter --version` validate the major override. Remove it when the SDK's declared range resolves to a reviewed patched release. Both image paths install the committed graph with `npm ci --ignore-scripts --omit=dev` because the published package declares no install-time lifecycle script and NemoClaw needs only its already-built CLI. diff --git a/agents/openclaw/mcporter-runtime/package-lock.json b/agents/openclaw/mcporter-runtime/package-lock.json index 97ce4a41738..51def2dffce 100644 --- a/agents/openclaw/mcporter-runtime/package-lock.json +++ b/agents/openclaw/mcporter-runtime/package-lock.json @@ -50,12 +50,12 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.5.tgz", + "integrity": "sha512-yQFvDmyDo3y6rEOJZDUYPJ49DIKTPpIk4kGvm40xx4Ejne0Pu9a1+exxPN+C1UppWK/WGZX9F++/Xs231tE86g==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" diff --git a/agents/openclaw/mcporter-runtime/package.json b/agents/openclaw/mcporter-runtime/package.json index a91d3579697..f5cb8564875 100644 --- a/agents/openclaw/mcporter-runtime/package.json +++ b/agents/openclaw/mcporter-runtime/package.json @@ -8,6 +8,9 @@ "dependencies": { "mcporter": "0.7.3" }, + "overrides": { + "@hono/node-server": "2.0.5" + }, "engines": { "node": ">=22.19.0" } diff --git a/docs/security/openclaw-2026.6.10-dependency-review.md b/docs/security/openclaw-2026.6.10-dependency-review.md index a02cf1cde80..a3a74ce8040 100644 --- a/docs/security/openclaw-2026.6.10-dependency-review.md +++ b/docs/security/openclaw-2026.6.10-dependency-review.md @@ -56,9 +56,15 @@ NPM_CONFIG_REGISTRY=https://registry.npmjs.org \ Revalidated on 2026-07-21: the command exited `0` under Node `v22.22.2`. This runtime satisfies the OpenClaw engine requirement of `>=22.19.0`. The remediated reviewed-archive graph reported `0` info, `1` low, `1` moderate, `0` high, and `0` critical findings across `766` total dependencies. -The mcporter locked graph reported no findings across `138` dependencies. +The remediated mcporter locked graph reported no findings across `138` dependencies. The configured `high` threshold therefore passed. +The unmodified mcporter graph resolves `@hono/node-server@1.19.14`, affected by +`GHSA-frvp-7c67-39w9`. Because the first patched release is outside the version +range declared by the locked MCP SDK, the dedicated runtime manifest pins the +exact reviewed `2.0.5` archive through npm's override mechanism. Focused ESM +and CommonJS Streamable HTTP transport probes cover the compatibility boundary. + The retained low finding is `GHSA-v422-hmwv-36x6` in `body-parser@2.0.0` through `2.2.2`. It remains in the reviewed Slack and Microsoft Teams plugin graphs. The retained moderate finding is `GHSA-j3f2-48v5-ccww` in `protobufjs@7.5.0` through `7.6.4`. diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index 17884af4170..c9192977ec2 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -3,7 +3,7 @@ # OpenClaw 2026.7.1 dependency review -Review date: 2026-07-20 +Review date: 2026-07-21 ## Decision @@ -63,6 +63,18 @@ finding that blocked the previous pin is gone. The remaining moderate The independently installed `nemoclaw/` plugin graph reports `0` vulnerabilities after resolving its direct `tar` dependency to `7.5.20`. +The separately locked `mcporter@0.7.3` runtime graph originally resolved +`@hono/node-server@1.19.14`, affected by `GHSA-frvp-7c67-39w9`. The locked +`@modelcontextprotocol/sdk@1.29.0` still declares `@hono/node-server@^1.19.9`, +while the first patched release is `2.0.5`. The dedicated runtime manifest pins +that exact reviewed release through npm's override mechanism. Its Node `>=20` +requirement remains inside the image's Node contract, and real ESM plus +CommonJS Streamable HTTP transport construction/start/close probes cover the +major-version compatibility boundary. The resulting `138`-dependency graph +reports `0` known vulnerabilities; image assembly keeps the low audit threshold, +signature verification, and exact committed lock mandatory. Remove the override +when the SDK's declared range resolves to a reviewed patched release. + The published Slack and Microsoft Teams plugin archives bundle `axios@1.16.0`. That version is in the affected range for the newly disclosed Axios inherited-proxy advisory. NemoClaw therefore rebuilds only these two reviewed diff --git a/test/e2e/live/openshell-gateway-upgrade-helpers.ts b/test/e2e/live/openshell-gateway-upgrade-helpers.ts index 658795a8335..97745649a0f 100644 --- a/test/e2e/live/openshell-gateway-upgrade-helpers.ts +++ b/test/e2e/live/openshell-gateway-upgrade-helpers.ts @@ -57,6 +57,51 @@ export function currentGatewayUpgradeInstallerArgs( return options.interactive ? [installer] : [installer, ...NON_INTERACTIVE_INSTALLER_ARGS]; } +// Frozen v0.0.74 and v0.0.89 sources run a live low-severity npm audit while +// assembling their historical image. Inject a two-stage fixture hook that +// changes only the cloned old Dockerfile to the repository's reviewed high +// threshold; the current candidate image keeps its low threshold unchanged. +export function patchHistoricalInstallerAdvisoryThreshold(source: string): string { + const needle = ' legacy_script="${source_root}/install.sh"\n'; + const hook = + String.raw` if [[ -n "\${NEMOCLAW_OLD_OPENCLAW_VERSION:-}" && -f "$payload_script" ]]; then + python3 - "$payload_script" <<'NEMOCLAW_OLD_AUDIT_THRESHOLD_PAYLOAD_PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text(encoding="utf-8") +needle = ' spin "Cloning \${_CLI_DISPLAY} source" clone_nemoclaw_ref "$release_ref" "$nemoclaw_src"\n' +hook = r''' python3 - "$nemoclaw_src/Dockerfile" <<'NEMOCLAW_OLD_AUDIT_THRESHOLD_DOCKERFILE_PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text(encoding="utf-8") +needle = "npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=low" +replacement = "npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=high" +if text.count(needle) != 1: + raise SystemExit(f"{path}: expected exactly one historical mcporter audit threshold") +path.write_text(text.replace(needle, replacement, 1), encoding="utf-8") +print("INFO: Historical upgrade fixture retains npm audit at the reviewed high threshold", flush=True) +NEMOCLAW_OLD_AUDIT_THRESHOLD_DOCKERFILE_PY +''' +if hook not in text: + if needle not in text: + raise SystemExit(f"{path}: old source clone hook not found") + text = text.replace(needle, needle + hook, 1) + path.write_text(text, encoding="utf-8") +NEMOCLAW_OLD_AUDIT_THRESHOLD_PAYLOAD_PY + fi +`.replaceAll("\\${", "${"); + + if (source.includes(hook)) return source; + if (!source.includes(needle)) { + throw new Error("historical installer bootstrap payload hook not found"); + } + return source.replace(needle, needle + hook); +} + export function expectedLegacyRegistryMetadata(nemoclawRef: string): { nemoclawVersion: string | undefined; fromDockerfile: null | undefined; diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index 11790a0ea87..c8b8ac8aec6 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -39,6 +39,7 @@ import { currentGatewayUpgradeInstallerArgs, expectedLegacyRegistryMetadata, oldGatewayUpgradeInstallerArgs, + patchHistoricalInstallerAdvisoryThreshold, upgradeGatewayCleanupScript, upgradeGatewayStateCleanupScript, validateLegacyGatewayUpgradeFixture, @@ -580,6 +581,11 @@ NEMOCLAW_OLD_PAYLOAD_PIN_PY fs.writeFileSync(installer, patchedText, "utf8"); } +function patchOldInstallerAdvisoryThresholdFixture(installer: string): void { + const text = fs.readFileSync(installer, "utf8"); + fs.writeFileSync(installer, patchHistoricalInstallerAdvisoryThreshold(text), "utf8"); +} + function createOldDockerWrapper(artifacts: ArtifactSink): string { const wrapperDir = artifacts.pathFor("old-docker-wrapper"); const logFile = artifacts.pathFor("old-docker-wrapper.log"); @@ -790,11 +796,16 @@ async function installOldNemoclawAndClaw( `downloaded ${OLD_NEMOCLAW_REF} installer must match its pinned SHA-256`, ).toBe(OLD_INSTALLER_SHA256); fs.chmodSync(oldInstaller, 0o755); - // v0.0.89 already contains the reviewed 2026.6.10 integrity/tarball pins. - // Keep it byte-representative of the installed base instead of injecting - // the legacy fixture adapter used by the established older lanes. - const oldInstallerFixturePatches = - OLD_NEMOCLAW_REF === "v0.0.89" ? [] : [patchOldInstallerFixture]; + // v0.0.89 already contains the reviewed 2026.6.10 integrity/tarball pins, so + // it does not need the old version-pin adapter. Both frozen releases whose + // Dockerfiles run a live advisory query need the bounded threshold adapter; + // otherwise advisory publication can block fixture setup before migration. + const oldInstallerFixturePatches = [ + ...(OLD_NEMOCLAW_REF === "v0.0.89" ? [] : [patchOldInstallerFixture]), + ...(["v0.0.74", "v0.0.89"].includes(OLD_NEMOCLAW_REF) + ? [patchOldInstallerAdvisoryThresholdFixture] + : []), + ]; for (const patchInstaller of oldInstallerFixturePatches) { patchInstaller(oldInstaller); } @@ -853,6 +864,11 @@ async function installOldNemoclawAndClaw( expect(oldLog, `old fixture must show pinned OpenClaw ${OLD_OPENCLAW_VERSION}`).toMatch( new RegExp(`OpenClaw ${oldOpenClawVersionPattern}|openclaw@${oldOpenClawVersionPattern}`), ); + if (["v0.0.74", "v0.0.89"].includes(OLD_NEMOCLAW_REF)) { + expect(oldLog).toContain( + "INFO: Historical upgrade fixture retains npm audit at the reviewed high threshold", + ); + } const openshellVersion = await bash(host, `openshell --version`, { artifactName: "old-openshell-version", diff --git a/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts b/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts index 698fd574463..b78488fa9f4 100644 --- a/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts +++ b/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts @@ -18,6 +18,7 @@ import { currentGatewayUpgradeInstallerArgs, expectedLegacyRegistryMetadata, oldGatewayUpgradeInstallerArgs, + patchHistoricalInstallerAdvisoryThreshold, upgradeGatewayCleanupScript, validateLegacyGatewayUpgradeFixture, } from "../live/openshell-gateway-upgrade-helpers.ts"; @@ -79,6 +80,62 @@ describe("OpenShell gateway upgrade workflow boundary", () => { ); }); + it("keeps frozen installer setup deterministic without weakening the candidate audit", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-historical-audit-")); + const installer = path.join(tmp, "install.sh"); + const payload = path.join(tmp, "payload.sh"); + const oldSource = path.join(tmp, "old-source"); + const dockerfile = path.join(oldSource, "Dockerfile"); + fs.mkdirSync(oldSource); + fs.writeFileSync( + dockerfile, + [ + "RUN npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit --omit=dev --audit-level=low; \\", + " npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime audit signatures", + ].join("\n"), + ); + fs.writeFileSync( + payload, + `#!/usr/bin/env bash +set -euo pipefail +nemoclaw_src="$1" +_CLI_DISPLAY=NemoClaw +release_ref=v0.0.89 +spin() { :; } + spin "Cloning \${_CLI_DISPLAY} source" clone_nemoclaw_ref "$release_ref" "$nemoclaw_src" +`, + ); + const installerSource = `#!/usr/bin/env bash +set -euo pipefail +payload_script="$1" +source_root=${JSON.stringify(tmp)} + legacy_script="\${source_root}/install.sh" +`; + const patchedInstaller = patchHistoricalInstallerAdvisoryThreshold(installerSource); + expect(patchHistoricalInstallerAdvisoryThreshold(patchedInstaller)).toBe(patchedInstaller); + expect(() => patchHistoricalInstallerAdvisoryThreshold("#!/usr/bin/env bash\n")).toThrow( + /bootstrap payload hook not found/, + ); + fs.writeFileSync(installer, patchedInstaller); + + try { + const patchPayload = spawnSync("bash", [installer, payload], { + encoding: "utf8", + env: { ...process.env, NEMOCLAW_OLD_OPENCLAW_VERSION: "2026.6.10" }, + }); + expect(patchPayload.status, patchPayload.stderr).toBe(0); + const patchDockerfile = spawnSync("bash", [payload, oldSource], { encoding: "utf8" }); + expect(patchDockerfile.status, patchDockerfile.stderr).toBe(0); + + const result = fs.readFileSync(dockerfile, "utf8"); + expect(result).toContain("mcporter-runtime audit --omit=dev --audit-level=high"); + expect(result).toContain("mcporter-runtime audit signatures"); + expect(result).not.toContain("mcporter-runtime audit --omit=dev --audit-level=low"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("pins the registry metadata written by each historical release fixture", () => { const absentMetadata = { nemoclawVersion: undefined, fromDockerfile: undefined }; expect(expectedLegacyRegistryMetadata("v0.0.36")).toEqual(absentMetadata); diff --git a/test/mcporter-supply-chain.test.ts b/test/mcporter-supply-chain.test.ts index fd34ae32b5c..66268d08e3a 100644 --- a/test/mcporter-supply-chain.test.ts +++ b/test/mcporter-supply-chain.test.ts @@ -18,6 +18,13 @@ const expectedIntegrity = "sha512-egoPVYqTnWb3NjRIxo+xc8OrAI0dlPrJm9pAiZx0pImuNIV5rKhGtTnIfH/Y1ldGPVu74ibj3KR5c9U/QSdQFA=="; const expectedTarball = "https://registry.npmjs.org/mcporter/-/mcporter-0.7.3.tgz"; const runtimePrefix = "npm --prefix /usr/local/lib/nemoclaw/mcporter-runtime"; +const patchedHonoNodeServerVersion = "2.0.5"; + +type PackageGraphNode = { + dependencies?: Record; + overridden?: boolean; + version?: string; +}; function extractIntegrityGate(contents: string): string { const startMarker = 'MCPORTER_EXPECTED_INTEGRITY=""'; @@ -70,12 +77,19 @@ describe("mcporter image supply-chain controls", () => { { cwd: runtimeDirectory, encoding: "utf8" }, ); expect(result.status, result.stderr).toBe(0); - const graph = JSON.parse(result.stdout) as { - dependencies?: Record; + const graph = JSON.parse(result.stdout) as PackageGraphNode & { problems?: string[]; }; expect(graph.problems).toBeUndefined(); - expect(graph.dependencies?.mcporter?.version).toBe(expectedVersion); + const mcporter = graph.dependencies?.mcporter; + const sdk = mcporter?.dependencies?.["@modelcontextprotocol/sdk"]; + const honoNodeServer = sdk?.dependencies?.["@hono/node-server"]; + expect(mcporter?.version).toBe(expectedVersion); + expect(sdk?.version).toBe("1.29.0"); + expect(honoNodeServer).toMatchObject({ + version: patchedHonoNodeServerVersion, + overridden: true, + }); }); it.each(dockerfiles)("pins and verifies the package in $name", ({ contents }) => { diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index 6ddfa3f028f..bd877642420 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -235,8 +235,11 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { "`0` info, `1` low, `1` moderate, `0` high, and `0` critical findings across `766` total dependencies", ); expect(review).toContain( - "The mcporter locked graph reported no findings across `138` dependencies", + "The remediated mcporter locked graph reported no findings across `138` dependencies", ); + expect(review).toContain("GHSA-frvp-7c67-39w9"); + expect(review).toContain("@hono/node-server@1.19.14"); + expect(review).toContain("exact reviewed `2.0.5`"); expect(review).toContain("GHSA-v422-hmwv-36x6"); expect(review).toContain("reviewed Slack and Microsoft Teams plugin graphs"); expect(review).toContain("GHSA-j3f2-48v5-ccww"); From 8aea05d71b5a61b03b91bc92bca2a03ccf6e3d36 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 12:38:33 -0700 Subject: [PATCH 22/24] test(e2e): keep upgrade assertions linear Signed-off-by: Aaron Erickson --- test/e2e/live/openshell-gateway-upgrade.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index c8b8ac8aec6..c0386211577 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -864,11 +864,11 @@ async function installOldNemoclawAndClaw( expect(oldLog, `old fixture must show pinned OpenClaw ${OLD_OPENCLAW_VERSION}`).toMatch( new RegExp(`OpenClaw ${oldOpenClawVersionPattern}|openclaw@${oldOpenClawVersionPattern}`), ); - if (["v0.0.74", "v0.0.89"].includes(OLD_NEMOCLAW_REF)) { - expect(oldLog).toContain( + expect( + oldLog.includes( "INFO: Historical upgrade fixture retains npm audit at the reviewed high threshold", - ); - } + ), + ).toBe(["v0.0.74", "v0.0.89"].includes(OLD_NEMOCLAW_REF)); const openshellVersion = await bash(host, `openshell --version`, { artifactName: "old-openshell-version", From 18045b40c9569d7f8288e65d45d7348fcdbd8ea7 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 21 Jul 2026 13:14:08 -0700 Subject: [PATCH 23/24] fix(security): remediate diagnostics Jaeger advisory Signed-off-by: Aaron Erickson --- Dockerfile | 11 +- .../openclaw-2026.6.10-dependency-review.md | 3 +- .../openclaw-2026.7.1-dependency-review.md | 42 ++- scripts/lib/openclaw-npm-remediation.mts | 311 +++++++++++++----- test/openclaw-dependency-review.test.ts | 35 +- test/openclaw-integrity-pin-suite.ts | 33 +- test/openclaw-npm-remediation.test.ts | 84 +++++ 7 files changed, 421 insertions(+), 98 deletions(-) diff --git a/Dockerfile b/Dockerfile index c23f1e69c32..471f5706654 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1072,9 +1072,18 @@ RUN set -eu; \ install_reviewed_openclaw_plugin() { \ plugin_spec="${1}@${OPENCLAW_VERSION}"; \ plugin_archive="$(verify_openclaw_plugin_integrity "$plugin_spec")"; \ + plugin_root="$(dirname "$plugin_archive")"; \ + case "$plugin_spec" in \ + "@openclaw/diagnostics-otel@2026.7.1") \ + remediation_json="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ + --archive "$plugin_archive" --package-spec "$plugin_spec" \ + --working-directory "$plugin_root")"; \ + plugin_archive="$(node -e 'const value = JSON.parse(process.argv[1]); if (!value.remediated || typeof value.archivePath !== "string") process.exit(1); process.stdout.write(value.archivePath)' "$remediation_json")" \ + ;; \ + esac; \ NPM_CONFIG_IGNORE_SCRIPTS=true npm_config_ignore_scripts=true \ openclaw plugins install "npm-pack:${plugin_archive}"; \ - rm -rf "$(dirname "$plugin_archive")"; \ + rm -rf "$plugin_root"; \ }; \ if [ "$NEMOCLAW_OPENCLAW_OTEL" = "1" ] || [ "$NEMOCLAW_WEB_SEARCH_ENABLED" = "1" ]; then \ test -n "$OPENCLAW_VERSION"; \ diff --git a/docs/security/openclaw-2026.6.10-dependency-review.md b/docs/security/openclaw-2026.6.10-dependency-review.md index a3a74ce8040..b9c761c3fef 100644 --- a/docs/security/openclaw-2026.6.10-dependency-review.md +++ b/docs/security/openclaw-2026.6.10-dependency-review.md @@ -85,7 +85,8 @@ The OpenClaw 2026.6.10 bump does not newly introduce an unfrozen OpenClaw transi This section is a point-in-time record of the remediation shipped for the 2026.6.10 runtime. The current 2026.7.1 path installs the reviewed core archive directly because its core graph already contains the fixed versions, and keeps -only the version-scoped Slack and Microsoft Teams Axios remediation. See +only the version-scoped Slack and Microsoft Teams Axios remediation plus the +diagnostics Jaeger remediation added for the 2026.7.1 archive. See [`openclaw-2026.7.1-dependency-review.md`](./openclaw-2026.7.1-dependency-review.md) for the active source and validation boundary. diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index c9192977ec2..fe926a71c4f 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -53,12 +53,14 @@ whose amd64 config reports Node `22.23.1`. - `sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==` - `https://registry.npmjs.org/tar/-/tar-7.5.20.tgz` -## Audit result and temporary Axios remediation +## Audit result and temporary dependency remediations -The exact reviewed archive graph contains `822` total dependencies and reports -`1` moderate, `0` high, and `0` critical vulnerabilities. The critical `tar` -finding that blocked the previous pin is gone. The remaining moderate -`protobufjs` finding is below the configured `high` threshold. +The exact reviewed archive graph contains `823` total dependencies and reports +`13` moderate, `0` high, and `0` critical vulnerabilities. The critical `tar` +finding that blocked the previous pin and the high Jaeger finding are gone. npm +audit expands the remaining `@hono/node-server` advisory through its affected +OpenClaw and MCP dependents, alongside the separate moderate `protobufjs` +finding. Both advisories are below the configured `high` threshold. The independently installed `nemoclaw/` plugin graph reports `0` vulnerabilities after resolving its direct `tar` dependency to `7.5.20`. @@ -87,6 +89,28 @@ plugin archives with this exact replacement graph: - `agent-base@6.0.2`, `sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==`. +The diagnostics plugin bundles `@opentelemetry/sdk-node@0.219.0`, whose exact +dependency on `@opentelemetry/propagator-jaeger@2.8.0` is affected by +`GHSA-45rx-2jwx-cxfr`. The helper changes only that bundled SDK dependency edge +to the first patched release and installs the matching Core package beneath it: + +- `@opentelemetry/propagator-jaeger@2.9.0`, + `sha512-4mYGty27rYvSM0jtp1ZUOqd3LfVRCYg9H5G9OFzSx5HViYToU21MFhWfco7x1HwXr7ER8yGOiCIHZUwjPksc0Q==`, + `https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.9.0.tgz`; +- nested `@opentelemetry/core@2.9.0`, + `sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==`, + `https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz`. + +Both replacements declare `Apache-2.0`. The nested Core keeps every other +consumer on the plugin's reviewed `2.8.0` graph while satisfying Jaeger's exact +`2.9.0` dependency. The helper fails closed unless the source archive still +contains diagnostics `2026.7.1`, SDK Node `0.219.0`, Jaeger `2.8.0`, and no +preexisting nested Core. The resulting package tree is pinned to +`sha512-2qyDTRPqNs97jo/pAWWfxAkVZyCXYqui/IjrGf4eEfYop1eGN8qBMJ/Kp/bJ/V18RNnYpMxHi5ECFelekVxcAQ==`. +Malformed Jaeger trace and baggage headers no longer throw, while a valid +Jaeger header still produces the expected trace and span context in the real +remediated package graph. + `scripts/lib/openclaw-npm-remediation.mts` verifies the original plugin and replacement package identities before it writes the archive. It rejects an upstream graph that no longer resolves Axios `1.16.0`. It then verifies the @@ -98,9 +122,11 @@ The tree hash opens each regular file without following symbolic links and validates the opened descriptor before it reads the content. This keeps the metadata and content checks bound to the same file. -This remediation is limited to `@openclaw/slack@2026.7.1` and -`@openclaw/msteams@2026.7.1`. Remove it when a reviewed stable OpenClaw plugin -release bundles Axios `>=1.18.0` and passes the repository audit. +The Axios remediation is limited to `@openclaw/slack@2026.7.1` and +`@openclaw/msteams@2026.7.1`; the Jaeger remediation is limited to +`@openclaw/diagnostics-otel@2026.7.1`. Remove each branch when a reviewed stable +OpenClaw plugin release bundles the corresponding patched graph and passes the +repository audit. The reviewed installer verifies each registry identity and downloaded tarball integrity. `scripts/lib/reviewed-npm-archive.mts` uses `npm pack --json` and diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index c34e2d157a8..35a7d9d2a67 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -26,6 +26,7 @@ type JsonObject = Record; type Remediation = Readonly<{ expectedPatchedTreeIntegrity: string; + kind: "axios" | "jaeger"; }>; type RemediationRequest = Readonly<{ @@ -59,15 +60,31 @@ const AGENT_BASE_VERSION = "6.0.2"; const AGENT_BASE_INTEGRITY = "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="; const AGENT_BASE_TARBALL = "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz"; +const JAEGER_PROPAGATOR_VERSION = "2.9.0"; +const JAEGER_PROPAGATOR_INTEGRITY = + "sha512-4mYGty27rYvSM0jtp1ZUOqd3LfVRCYg9H5G9OFzSx5HViYToU21MFhWfco7x1HwXr7ER8yGOiCIHZUwjPksc0Q=="; +const JAEGER_PROPAGATOR_TARBALL = + "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.9.0.tgz"; +const OTEL_CORE_VERSION = "2.9.0"; +const OTEL_CORE_INTEGRITY = + "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="; +const OTEL_CORE_TARBALL = "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz"; const REMEDIATIONS: Readonly> = Object.freeze({ + "@openclaw/diagnostics-otel@2026.7.1": { + expectedPatchedTreeIntegrity: + "sha512-2qyDTRPqNs97jo/pAWWfxAkVZyCXYqui/IjrGf4eEfYop1eGN8qBMJ/Kp/bJ/V18RNnYpMxHi5ECFelekVxcAQ==", + kind: "jaeger", + }, "@openclaw/msteams@2026.7.1": { expectedPatchedTreeIntegrity: "sha512-FL4l65gEbbwtDd9Ogr69+xBNzIfE4YS8Hib36G+kcmX+T0oB1zL+/qs6b4bJc+ygTsh60H3yqpFbXoQeN05JYQ==", + kind: "axios", }, "@openclaw/slack@2026.7.1": { expectedPatchedTreeIntegrity: "sha512-4ThnsNS+yBlFSkTaQn2xosxrDu1s0vrxcqka5QqFj+8dCEaTa9JVLRgNniYV/QNhO53wc7a2R5oQFElzYspT2w==", + kind: "axios", }, }); @@ -290,6 +307,70 @@ export function patchOpenClawPluginPackageGraph( writeJson(shrinkwrapPath, shrinkwrap); } +export function patchOpenClawDiagnosticsOtelPackageGraph(packageDirectory: string): void { + const packageSpec = "@openclaw/diagnostics-otel@2026.7.1"; + const packageJsonPath = join(packageDirectory, "package.json"); + const shrinkwrapPath = join(packageDirectory, "npm-shrinkwrap.json"); + const packageJson = readJson(packageJsonPath); + requirePackageIdentity(packageJson, "@openclaw/diagnostics-otel", "2026.7.1", "OpenClaw plugin"); + + const shrinkwrap = readJson(shrinkwrapPath); + if (shrinkwrap.lockfileVersion !== 3 || !shrinkwrap.packages?.[""]) { + throw new Error(`${packageSpec} must ship an npm lockfileVersion 3 shrinkwrap`); + } + const sdkKey = "node_modules/@opentelemetry/sdk-node"; + const sdk = shrinkwrap.packages[sdkKey] as JsonObject | undefined; + if ( + sdk?.version !== "0.219.0" || + sdk.dependencies?.["@opentelemetry/propagator-jaeger"] !== "2.8.0" + ) { + throw new Error( + `${packageSpec} must resolve ${sdkKey} with Jaeger propagator 2.8.0 before remediation`, + ); + } + sdk.dependencies["@opentelemetry/propagator-jaeger"] = JAEGER_PROPAGATOR_VERSION; + + const sdkPackageJsonPath = join(packageDirectory, sdkKey, "package.json"); + const sdkPackageJson = readJson(sdkPackageJsonPath); + requirePackageIdentity(sdkPackageJson, "@opentelemetry/sdk-node", "0.219.0", "Bundled SDK"); + if (sdkPackageJson.dependencies?.["@opentelemetry/propagator-jaeger"] !== "2.8.0") { + throw new Error(`${packageSpec} bundled SDK Jaeger dependency changed before remediation`); + } + sdkPackageJson.dependencies["@opentelemetry/propagator-jaeger"] = JAEGER_PROPAGATOR_VERSION; + + const jaegerKey = "node_modules/@opentelemetry/propagator-jaeger"; + const jaeger = shrinkwrap.packages[jaegerKey] as JsonObject | undefined; + if (jaeger?.version !== "2.8.0" || jaeger.dependencies?.["@opentelemetry/core"] !== "2.8.0") { + throw new Error(`${packageSpec} must resolve ${jaegerKey} to 2.8.0 before remediation`); + } + shrinkwrap.packages[jaegerKey] = { + version: JAEGER_PROPAGATOR_VERSION, + resolved: JAEGER_PROPAGATOR_TARBALL, + integrity: JAEGER_PROPAGATOR_INTEGRITY, + license: "Apache-2.0", + dependencies: { "@opentelemetry/core": OTEL_CORE_VERSION }, + engines: { node: "^18.19.0 || >=20.6.0" }, + peerDependencies: { "@opentelemetry/api": ">=1.0.0 <1.10.0" }, + }; + + const coreKey = `${jaegerKey}/node_modules/@opentelemetry/core`; + if (shrinkwrap.packages[coreKey]) { + throw new Error(`${packageSpec} already has a nested Jaeger core dependency`); + } + shrinkwrap.packages[coreKey] = { + version: OTEL_CORE_VERSION, + resolved: OTEL_CORE_TARBALL, + integrity: OTEL_CORE_INTEGRITY, + license: "Apache-2.0", + dependencies: { "@opentelemetry/semantic-conventions": "^1.29.0" }, + engines: { node: "^18.19.0 || >=20.6.0" }, + peerDependencies: { "@opentelemetry/api": ">=1.0.0 <1.10.0" }, + }; + + writeJson(sdkPackageJsonPath, sdkPackageJson); + writeJson(shrinkwrapPath, shrinkwrap); +} + function copyReplacementPackage(source: string, destination: string): void { rmSync(destination, { recursive: true, force: true }); mkdirSync(resolve(destination, ".."), { recursive: true, mode: 0o755 }); @@ -314,7 +395,8 @@ function packReplacement( } export function buildRemediatedOpenClawPluginArchive(request: BuildRequest): RemediatedArchive { - if (!REMEDIATIONS[request.packageSpec]) { + const remediation = REMEDIATIONS[request.packageSpec]; + if (!remediation) { throw new Error(`No OpenClaw npm remediation is defined for ${request.packageSpec}`); } const env = { @@ -335,89 +417,150 @@ export function buildRemediatedOpenClawPluginArchive(request: BuildRequest): Rem remediationRoot, env, ); - const axiosArchive = packReplacement( - `axios@${AXIOS_VERSION}`, - AXIOS_INTEGRITY, - AXIOS_TARBALL, - remediationRoot, - env, - ); - const httpsProxyAgentArchive = packReplacement( - `https-proxy-agent@${HTTPS_PROXY_AGENT_VERSION}`, - HTTPS_PROXY_AGENT_INTEGRITY, - HTTPS_PROXY_AGENT_TARBALL, - remediationRoot, - env, - ); - const agentBaseArchive = packReplacement( - `agent-base@${AGENT_BASE_VERSION}`, - AGENT_BASE_INTEGRITY, - AGENT_BASE_TARBALL, - remediationRoot, - env, - ); - const axiosPackage = extractArchive( - axiosArchive.archivePath, - join(remediationRoot, "axios"), - remediationRoot, - env, - ); - const httpsProxyAgentPackage = extractArchive( - httpsProxyAgentArchive.archivePath, - join(remediationRoot, "https-proxy-agent"), - remediationRoot, - env, - ); - const agentBasePackage = extractArchive( - agentBaseArchive.archivePath, - join(remediationRoot, "agent-base"), - remediationRoot, - env, - ); - const axiosPackageJson = readJson(join(axiosPackage, "package.json")); - const httpsProxyAgentPackageJson = readJson(join(httpsProxyAgentPackage, "package.json")); - const agentBasePackageJson = readJson(join(agentBasePackage, "package.json")); - requirePackageIdentity(axiosPackageJson, "axios", AXIOS_VERSION, "Axios remediation package"); - requirePackageIdentity( - httpsProxyAgentPackageJson, - "https-proxy-agent", - HTTPS_PROXY_AGENT_VERSION, - "Axios proxy remediation package", - ); - requirePackageIdentity( - agentBasePackageJson, - "agent-base", - AGENT_BASE_VERSION, - "Axios agent-base remediation package", - ); - requireDependencyShape( - axiosPackageJson, - { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0", - }, - "axios@1.18.0", - ); - requireDependencyShape( - httpsProxyAgentPackageJson, - { "agent-base": "6", debug: "4" }, - "https-proxy-agent@5.0.1", - ); - requireDependencyShape(agentBasePackageJson, { debug: "4" }, "agent-base@6.0.2"); + if (remediation.kind === "axios") { + const axiosArchive = packReplacement( + `axios@${AXIOS_VERSION}`, + AXIOS_INTEGRITY, + AXIOS_TARBALL, + remediationRoot, + env, + ); + const httpsProxyAgentArchive = packReplacement( + `https-proxy-agent@${HTTPS_PROXY_AGENT_VERSION}`, + HTTPS_PROXY_AGENT_INTEGRITY, + HTTPS_PROXY_AGENT_TARBALL, + remediationRoot, + env, + ); + const agentBaseArchive = packReplacement( + `agent-base@${AGENT_BASE_VERSION}`, + AGENT_BASE_INTEGRITY, + AGENT_BASE_TARBALL, + remediationRoot, + env, + ); + const axiosPackage = extractArchive( + axiosArchive.archivePath, + join(remediationRoot, "axios"), + remediationRoot, + env, + ); + const httpsProxyAgentPackage = extractArchive( + httpsProxyAgentArchive.archivePath, + join(remediationRoot, "https-proxy-agent"), + remediationRoot, + env, + ); + const agentBasePackage = extractArchive( + agentBaseArchive.archivePath, + join(remediationRoot, "agent-base"), + remediationRoot, + env, + ); + const axiosPackageJson = readJson(join(axiosPackage, "package.json")); + const httpsProxyAgentPackageJson = readJson(join(httpsProxyAgentPackage, "package.json")); + const agentBasePackageJson = readJson(join(agentBasePackage, "package.json")); + requirePackageIdentity(axiosPackageJson, "axios", AXIOS_VERSION, "Axios remediation package"); + requirePackageIdentity( + httpsProxyAgentPackageJson, + "https-proxy-agent", + HTTPS_PROXY_AGENT_VERSION, + "Axios proxy remediation package", + ); + requirePackageIdentity( + agentBasePackageJson, + "agent-base", + AGENT_BASE_VERSION, + "Axios agent-base remediation package", + ); + requireDependencyShape( + axiosPackageJson, + { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0", + }, + "axios@1.18.0", + ); + requireDependencyShape( + httpsProxyAgentPackageJson, + { "agent-base": "6", debug: "4" }, + "https-proxy-agent@5.0.1", + ); + requireDependencyShape(agentBasePackageJson, { debug: "4" }, "agent-base@6.0.2"); - const axiosTarget = join(sourcePackage, "node_modules", "axios"); - copyReplacementPackage(axiosPackage, axiosTarget); - copyReplacementPackage( - httpsProxyAgentPackage, - join(axiosTarget, "node_modules", "https-proxy-agent"), - ); - copyReplacementPackage( - agentBasePackage, - join(axiosTarget, "node_modules", "https-proxy-agent", "node_modules", "agent-base"), - ); - patchOpenClawPluginPackageGraph(sourcePackage, request.packageSpec); + const axiosTarget = join(sourcePackage, "node_modules", "axios"); + copyReplacementPackage(axiosPackage, axiosTarget); + copyReplacementPackage( + httpsProxyAgentPackage, + join(axiosTarget, "node_modules", "https-proxy-agent"), + ); + copyReplacementPackage( + agentBasePackage, + join(axiosTarget, "node_modules", "https-proxy-agent", "node_modules", "agent-base"), + ); + patchOpenClawPluginPackageGraph(sourcePackage, request.packageSpec); + } else { + const jaegerArchive = packReplacement( + `@opentelemetry/propagator-jaeger@${JAEGER_PROPAGATOR_VERSION}`, + JAEGER_PROPAGATOR_INTEGRITY, + JAEGER_PROPAGATOR_TARBALL, + remediationRoot, + env, + ); + const coreArchive = packReplacement( + `@opentelemetry/core@${OTEL_CORE_VERSION}`, + OTEL_CORE_INTEGRITY, + OTEL_CORE_TARBALL, + remediationRoot, + env, + ); + const jaegerPackage = extractArchive( + jaegerArchive.archivePath, + join(remediationRoot, "propagator-jaeger"), + remediationRoot, + env, + ); + const corePackage = extractArchive( + coreArchive.archivePath, + join(remediationRoot, "otel-core"), + remediationRoot, + env, + ); + const jaegerPackageJson = readJson(join(jaegerPackage, "package.json")); + const corePackageJson = readJson(join(corePackage, "package.json")); + requirePackageIdentity( + jaegerPackageJson, + "@opentelemetry/propagator-jaeger", + JAEGER_PROPAGATOR_VERSION, + "Jaeger remediation package", + ); + requirePackageIdentity( + corePackageJson, + "@opentelemetry/core", + OTEL_CORE_VERSION, + "OpenTelemetry core remediation package", + ); + requireDependencyShape( + jaegerPackageJson, + { "@opentelemetry/core": OTEL_CORE_VERSION }, + `@opentelemetry/propagator-jaeger@${JAEGER_PROPAGATOR_VERSION}`, + ); + requireDependencyShape( + corePackageJson, + { "@opentelemetry/semantic-conventions": "^1.29.0" }, + `@opentelemetry/core@${OTEL_CORE_VERSION}`, + ); + + const jaegerTarget = join(sourcePackage, "node_modules", "@opentelemetry", "propagator-jaeger"); + copyReplacementPackage(jaegerPackage, jaegerTarget); + copyReplacementPackage( + corePackage, + join(jaegerTarget, "node_modules", "@opentelemetry", "core"), + ); + patchOpenClawDiagnosticsOtelPackageGraph(sourcePackage); + } const outputDirectory = join(remediationRoot, "output"); mkdirSync(outputDirectory, { recursive: true, mode: 0o700 }); @@ -486,7 +629,7 @@ if (isMainModule()) { try { console.log( JSON.stringify( - buildRemediatedOpenClawPluginArchive({ + remediateReviewedOpenClawPluginArchive({ archivePath: value("--archive"), packageSpec: value("--package-spec"), workingDirectory: value("--working-directory"), diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index bd877642420..6a500fd0982 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -16,6 +16,12 @@ const DEPENDENCY_REVIEW = path.join( "security", "openclaw-2026.6.10-dependency-review.md", ); +const ACTIVE_DEPENDENCY_REVIEW = path.join( + REPO_ROOT, + "docs", + "security", + "openclaw-2026.7.1-dependency-review.md", +); const CODEX_ACP_TARBALL = "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.11.1.tgz"; const OPENCLAW_TARBALL = "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz"; @@ -128,6 +134,25 @@ function runBaseImageBuildArgGuard( } describe("OpenClaw 2026.6.10 dependency review contract", () => { + it("pins the active diagnostics Jaeger remediation to the shipped install path", () => { + const review = readFileSync(ACTIVE_DEPENDENCY_REVIEW, "utf-8"); + + expect(review).toContain("GHSA-45rx-2jwx-cxfr"); + expect(review).toContain("@opentelemetry/propagator-jaeger@2.9.0"); + expect(review).toContain("nested `@opentelemetry/core@2.9.0`"); + expect(review).toContain( + "sha512-4mYGty27rYvSM0jtp1ZUOqd3LfVRCYg9H5G9OFzSx5HViYToU21MFhWfco7x1HwXr7ER8yGOiCIHZUwjPksc0Q==", + ); + expect(review).toContain( + "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + ); + expect(review).toContain( + "sha512-2qyDTRPqNs97jo/pAWWfxAkVZyCXYqui/IjrGf4eEfYop1eGN8qBMJ/Kp/bJ/V18RNnYpMxHi5ECFelekVxcAQ==", + ); + expect(review).toContain("SDK Node `0.219.0`"); + expect(review).toContain("preexisting nested Core"); + }); + it("keeps advisor disposition evidence in the dependency review note", () => { const review = readFileSync(DEPENDENCY_REVIEW, "utf-8"); @@ -463,8 +488,13 @@ optional_plugin_block="$(sed -n '/# Install non-messaging OpenClaw plugins that check_contains "$optional_plugin_block" '/scripts/lib/reviewed-npm-archive.mts' "optional plugin shared helper" check_contains "$optional_plugin_block" '--package-spec "$plugin_spec" --integrity "$expected_integrity"' "optional plugin reviewed identity" check_contains "$optional_plugin_block" '--tarball-url "$expected_tarball"' "optional plugin reviewed tarball" +check_contains "$optional_plugin_block" '/scripts/lib/openclaw-npm-remediation.mts' "optional plugin remediation helper" +check_contains "$optional_plugin_block" '"@openclaw/diagnostics-otel@2026.7.1")' "diagnostics remediation identity" +check_contains "$optional_plugin_block" '--working-directory "$plugin_root"' "diagnostics remediation workspace" +check_contains "$optional_plugin_block" 'if (!value.remediated || typeof value.archivePath !== "string")' "diagnostics remediation result guard" +check_contains "$optional_plugin_block" 'plugin_root="$(dirname "$plugin_archive")"' "optional plugin cleanup root" check_contains "$optional_plugin_block" 'openclaw plugins install "npm-pack:\${plugin_archive}"' "optional plugin npm-pack install" -check_contains "$optional_plugin_block" 'rm -rf "$(dirname "$plugin_archive")"' "optional plugin cleanup" +check_contains "$optional_plugin_block" 'rm -rf "$plugin_root"' "optional plugin cleanup" check_not_contains "$optional_plugin_block" 'pack_reviewed_npm_tarball' "optional plugin inline pack helper" grep -Fq 'packReviewedNpmArchive({' "$messaging_build_applier" @@ -481,6 +511,9 @@ check_not_contains "$optional_plugin_block" 'pack_reviewed_npm_tarball' "optiona grep -Fq 'expectedPatchedTreeIntegrity' "$remediation_helper" grep -Fq 'hashPackageTree' "$remediation_helper" grep -Fq 'validateArchiveMembers(archivePath' "$remediation_helper" + remediation_cli_block="$(sed -n '/if (isMainModule())/,$p' "$remediation_helper")" + check_contains "$remediation_cli_block" 'remediateReviewedOpenClawPluginArchive({' "remediation CLI tree-integrity enforcement" + check_not_contains "$remediation_cli_block" 'buildRemediatedOpenClawPluginArchive({' "unenforced remediation CLI path" ! grep -Fq 'npmViewString(' "$messaging_build_applier" ! grep -Fq 'resolveNpmPackArchivePath(' "$messaging_build_applier" issue_4434_patch=${JSON.stringify(ISSUE_4434_PATCH)} diff --git a/test/openclaw-integrity-pin-suite.ts b/test/openclaw-integrity-pin-suite.ts index 291c92aaa11..f002902d52a 100644 --- a/test/openclaw-integrity-pin-suite.ts +++ b/test/openclaw-integrity-pin-suite.ts @@ -375,6 +375,7 @@ function runOptionalOpenClawPluginBlock( const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-plugin-integrity-")); const log = path.join(tmp, "calls.log"); const reviewedNpmExecutable = path.join(tmp, "reviewed-npm-fixture"); + const remediationFixture = path.join(tmp, "remediation-fixture.cjs"); fs.writeFileSync( reviewedNpmExecutable, [ @@ -405,6 +406,26 @@ function runOptionalOpenClawPluginBlock( ].join("\n"), { mode: 0o755 }, ); + fs.writeFileSync( + remediationFixture, + [ + "const fs = require('node:fs');", + "const path = require('node:path');", + `const log = ${JSON.stringify(log)};`, + "const args = process.argv.slice(2);", + "const value = (name) => { const index = args.indexOf(name); if (index < 0 || !args[index + 1]) process.exit(1); return args[index + 1]; };", + "const archive = value('--archive');", + "const workingDirectory = value('--working-directory');", + "const outputDirectory = path.join(workingDirectory, 'remediated');", + "fs.mkdirSync(outputDirectory, { recursive: true });", + "const archivePath = path.join(outputDirectory, path.basename(archive));", + "fs.copyFileSync(archive, archivePath);", + "fs.appendFileSync(log, `remediate ${args.join(' ')}\\n`);", + "process.stdout.write(JSON.stringify({ archivePath, integrity: 'sha512-remediated', remediated: true }));", + "", + ].join("\n"), + { mode: 0o700 }, + ); const script = [ "#!/usr/bin/env bash", "set -euo pipefail", @@ -442,7 +463,9 @@ function runOptionalOpenClawPluginBlock( " esac", " return 1", "}", - command.replaceAll("/scripts/lib/reviewed-npm-archive.mts", REVIEWED_NPM_ARCHIVE_HELPER), + command + .replaceAll("/scripts/lib/reviewed-npm-archive.mts", REVIEWED_NPM_ARCHIVE_HELPER) + .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationFixture), ].join("\n"); const scriptPath = path.join(tmp, "run.sh"); fs.writeFileSync(scriptPath, script, { mode: 0o700 }); @@ -504,10 +527,10 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(reviewNote).toContain("@openclaw/diagnostics-otel@2026.7.1"); expect(reviewNote).toContain("@openclaw/brave-plugin@2026.7.1"); expect(reviewNote).toContain("@tencent-weixin/openclaw-weixin@2.4.3"); - expect(reviewNote).toContain("`1` moderate"); + expect(reviewNote).toContain("`13` moderate"); expect(reviewNote).toContain("`0` high"); expect(reviewNote).toContain("`0` critical"); - expect(reviewNote).toContain("`822` total dependencies"); + expect(reviewNote).toContain("`823` total dependencies"); expect(reviewNote).toContain( "`dist/pipeline.runtime-*.js`, which exports `prepareSlackMessage`", ); @@ -661,6 +684,10 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(calls).toMatch( /openclaw plugins install npm-pack:\S*\/diagnostics-otel-2026\.7\.1\.tgz\n/, ); + expect(calls).toContain(`remediate --archive`); + expect(calls).toContain( + `--package-spec @openclaw/diagnostics-otel@${PINNED_OPENCLAW_VERSION}`, + ); expect(calls).toContain( `npm view @openclaw/brave-plugin@${PINNED_OPENCLAW_VERSION} dist.integrity`, ); diff --git a/test/openclaw-npm-remediation.test.ts b/test/openclaw-npm-remediation.test.ts index 8bef030f728..d6f34b94a67 100644 --- a/test/openclaw-npm-remediation.test.ts +++ b/test/openclaw-npm-remediation.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { hashPackageTree, + patchOpenClawDiagnosticsOtelPackageGraph, patchOpenClawPluginPackageGraph, } from "../scripts/lib/openclaw-npm-remediation.mts"; @@ -62,6 +63,53 @@ function writeFixture(axiosVersion = "1.16.0"): string { return directory; } +function writeDiagnosticsFixture(jaegerVersion = "2.8.0"): string { + const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-otel-remediation-")); + temporaryDirectories.push(directory); + const sdkDirectory = path.join(directory, "node_modules", "@opentelemetry", "sdk-node"); + mkdirSync(sdkDirectory, { recursive: true }); + writeFileSync( + path.join(directory, "package.json"), + `${JSON.stringify({ name: "@openclaw/diagnostics-otel", version: "2026.7.1" }, null, 2)}\n`, + ); + writeFileSync( + path.join(sdkDirectory, "package.json"), + `${JSON.stringify( + { + name: "@opentelemetry/sdk-node", + version: "0.219.0", + dependencies: { "@opentelemetry/propagator-jaeger": jaegerVersion }, + }, + null, + 2, + )}\n`, + ); + writeFileSync( + path.join(directory, "npm-shrinkwrap.json"), + `${JSON.stringify( + { + name: "@openclaw/diagnostics-otel", + version: "2026.7.1", + lockfileVersion: 3, + packages: { + "": { name: "@openclaw/diagnostics-otel", version: "2026.7.1" }, + "node_modules/@opentelemetry/sdk-node": { + version: "0.219.0", + dependencies: { "@opentelemetry/propagator-jaeger": jaegerVersion }, + }, + "node_modules/@opentelemetry/propagator-jaeger": { + version: jaegerVersion, + dependencies: { "@opentelemetry/core": jaegerVersion }, + }, + }, + }, + null, + 2, + )}\n`, + ); + return directory; +} + function readJson(file: string): T { return JSON.parse(readFileSync(file, "utf-8")) as T; } @@ -170,4 +218,40 @@ describe("OpenClaw npm remediation", () => { "must resolve node_modules/axios to 1.16.0 before remediation", ); }); + + it("replaces the reviewed Jaeger propagator with its aligned patched core", () => { + const directory = writeDiagnosticsFixture(); + + patchOpenClawDiagnosticsOtelPackageGraph(directory); + + expect( + readPackageField( + path.join(directory, "node_modules", "@opentelemetry", "sdk-node"), + "dependencies.@opentelemetry/propagator-jaeger", + ), + ).toBe("2.9.0"); + const shrinkwrap = readJson<{ + packages: Record }>; + }>(path.join(directory, "npm-shrinkwrap.json")); + expect(shrinkwrap.packages["node_modules/@opentelemetry/propagator-jaeger"]).toMatchObject({ + version: "2.9.0", + dependencies: { "@opentelemetry/core": "2.9.0" }, + }); + expect( + shrinkwrap.packages[ + "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/core" + ], + ).toMatchObject({ + version: "2.9.0", + dependencies: { "@opentelemetry/semantic-conventions": "^1.29.0" }, + }); + }); + + it("rejects a diagnostics Jaeger graph that changed after review", () => { + const directory = writeDiagnosticsFixture("2.8.1"); + + expect(() => patchOpenClawDiagnosticsOtelPackageGraph(directory)).toThrow( + "with Jaeger propagator 2.8.0 before remediation", + ); + }); }); From 4c655cf9e023cb2a64d057979bfd8d13f85059e2 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Tue, 21 Jul 2026 14:12:08 -0700 Subject: [PATCH 24/24] test(security): verify Jaeger header remediation Signed-off-by: Apurv Kumaria --- .github/workflows/main.yaml | 5 + .../openclaw-2026.7.1-dependency-review.md | 15 +- scripts/lib/openclaw-npm-remediation.mts | 1 + test/openclaw-dependency-review.test.ts | 8 + ...penclaw-diagnostics-jaeger-runtime.test.ts | 222 ++++++++++++++++++ 5 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 test/openclaw-diagnostics-jaeger-runtime.test.ts diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index ff6cdaba33d..458fba0a4e4 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -122,6 +122,11 @@ jobs: NEMOCLAW_REAL_OPENCLAW_DIST_HARNESS: "1" run: npx vitest run --project integration test/openclaw-real-patched-dist-harness.test.ts --silent=false --reporter=default + - name: Verify reviewed Jaeger header handling + env: + NEMOCLAW_REAL_OPENCLAW_JAEGER_HARNESS: "1" + run: npx vitest run --project integration test/openclaw-diagnostics-jaeger-runtime.test.ts --silent=false --reporter=default + - name: Audit managed OpenClaw security finding suppressions env: NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS: "1" diff --git a/docs/security/openclaw-2026.7.1-dependency-review.md b/docs/security/openclaw-2026.7.1-dependency-review.md index fe926a71c4f..b18d42964a7 100644 --- a/docs/security/openclaw-2026.7.1-dependency-review.md +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -107,9 +107,17 @@ consumer on the plugin's reviewed `2.8.0` graph while satisfying Jaeger's exact contains diagnostics `2026.7.1`, SDK Node `0.219.0`, Jaeger `2.8.0`, and no preexisting nested Core. The resulting package tree is pinned to `sha512-2qyDTRPqNs97jo/pAWWfxAkVZyCXYqui/IjrGf4eEfYop1eGN8qBMJ/Kp/bJ/V18RNnYpMxHi5ECFelekVxcAQ==`. -Malformed Jaeger trace and baggage headers no longer throw, while a valid -Jaeger header still produces the expected trace and span context in the real -remediated package graph. +The trusted main-only +`test/openclaw-diagnostics-jaeger-runtime.test.ts` harness runs with +`NEMOCLAW_REAL_OPENCLAW_JAEGER_HARNESS=1`. +It materializes the exact reviewed diagnostics archive, applies the production +remediation, and installs that local archive with lifecycle scripts disabled. +The child-process probe confirms these results: + +- malformed percent-encoded `uber-trace-id` and `uberctx-*` headers do not throw; +- malformed baggage is ignored; +- valid `uberctx-test` baggage remains available; +- a valid Jaeger header produces the expected trace and span context. `scripts/lib/openclaw-npm-remediation.mts` verifies the original plugin and replacement package identities before it writes the archive. It rejects an @@ -127,6 +135,7 @@ The Axios remediation is limited to `@openclaw/slack@2026.7.1` and `@openclaw/diagnostics-otel@2026.7.1`. Remove each branch when a reviewed stable OpenClaw plugin release bundles the corresponding patched graph and passes the repository audit. +Issue #7337 tracks removal of the Jaeger branch and its exact replacement pins. The reviewed installer verifies each registry identity and downloaded tarball integrity. `scripts/lib/reviewed-npm-archive.mts` uses `npm pack --json` and diff --git a/scripts/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index 35a7d9d2a67..322042219af 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -71,6 +71,7 @@ const OTEL_CORE_INTEGRITY = const OTEL_CORE_TARBALL = "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz"; const REMEDIATIONS: Readonly> = Object.freeze({ + // #7337: remove this branch only after a reviewed diagnostics release ships a safe SDK graph. "@openclaw/diagnostics-otel@2026.7.1": { expectedPatchedTreeIntegrity: "sha512-2qyDTRPqNs97jo/pAWWfxAkVZyCXYqui/IjrGf4eEfYop1eGN8qBMJ/Kp/bJ/V18RNnYpMxHi5ECFelekVxcAQ==", diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index 6a500fd0982..3656b1b38d9 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -151,6 +151,8 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { ); expect(review).toContain("SDK Node `0.219.0`"); expect(review).toContain("preexisting nested Core"); + expect(review).toContain("test/openclaw-diagnostics-jaeger-runtime.test.ts"); + expect(review).toContain("NEMOCLAW_REAL_OPENCLAW_JAEGER_HARNESS=1"); }); it("keeps advisor disposition evidence in the dependency review note", () => { @@ -661,6 +663,12 @@ grep -Fq -- '--phase post-agent-install' Dockerfile expect(requiredStep(mainJob, "Audit the real patched OpenClaw distribution").run).toContain( "test/openclaw-real-patched-dist-harness.test.ts", ); + expect(requiredStep(mainJob, "Verify reviewed Jaeger header handling").env).toEqual({ + NEMOCLAW_REAL_OPENCLAW_JAEGER_HARNESS: "1", + }); + expect(requiredStep(mainJob, "Verify reviewed Jaeger header handling").run).toContain( + "test/openclaw-diagnostics-jaeger-runtime.test.ts", + ); expect( requiredStep(mainJob, "Audit managed OpenClaw security finding suppressions").env, ).toEqual({ NEMOCLAW_REAL_OPENCLAW_AUDIT_HARNESS: "1" }); diff --git a/test/openclaw-diagnostics-jaeger-runtime.test.ts b/test/openclaw-diagnostics-jaeger-runtime.test.ts new file mode 100644 index 00000000000..28902675fd7 --- /dev/null +++ b/test/openclaw-diagnostics-jaeger-runtime.test.ts @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +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 { remediateReviewedOpenClawPluginArchive } from "../scripts/lib/openclaw-npm-remediation.mts"; +import { packReviewedNpmArchive } from "../scripts/lib/reviewed-npm-archive.mts"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const RUNTIME_HARNESS_ENV = "NEMOCLAW_REAL_OPENCLAW_JAEGER_HARNESS"; +const RUNTIME_TIMEOUT_MS = 180_000; + +interface ReviewedPackage { + integrity: string; + label: string; + packageSpec: string; + tarballUrl: string; +} + +const JAEGER_RUNTIME_PROBE = String.raw` +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +const diagnosticsRoot = process.argv[1]; +assert.ok(path.isAbsolute(diagnosticsRoot), "diagnostics root must be absolute"); + +const diagnosticsRequire = createRequire(path.join(diagnosticsRoot, "package.json")); +const sdkEntry = diagnosticsRequire.resolve("@opentelemetry/sdk-node"); +const sdkRequire = createRequire(sdkEntry); +const jaegerEntry = sdkRequire.resolve("@opentelemetry/propagator-jaeger"); +const jaegerRequire = createRequire(jaegerEntry); +const { JaegerPropagator } = sdkRequire("@opentelemetry/propagator-jaeger"); +const { ROOT_CONTEXT, propagation, trace } = jaegerRequire("@opentelemetry/api"); + +let packageRoot = path.dirname(jaegerEntry); +let packageJson; +while (packageRoot !== path.dirname(packageRoot)) { + const candidate = path.join(packageRoot, "package.json"); + if (fs.existsSync(candidate)) { + const parsed = JSON.parse(fs.readFileSync(candidate, "utf-8")); + if (parsed.name === "@opentelemetry/propagator-jaeger") { + packageJson = parsed; + break; + } + } + packageRoot = path.dirname(packageRoot); +} +assert.ok(packageJson, "could not resolve the physical Jaeger package identity"); +const versionMatch = /^(\d+)\.(\d+)\.(\d+)$/.exec(packageJson.version); +assert.ok(versionMatch, "Jaeger package must use a stable semantic version"); +const version = versionMatch.slice(1).map(Number); +assert.ok( + version[0] > 2 || (version[0] === 2 && (version[1] > 9 || (version[1] === 9 && version[2] >= 0))), + "Jaeger package must be at least 2.9.0", +); + +const propagator = new JaegerPropagator(); +const getter = { + get(carrier, key) { + return carrier[key]; + }, + keys(carrier) { + return Object.keys(carrier); + }, +}; + +for (const malformed of ["%ZZ", "abc%G:123:0:01", "%"]) { + const context = propagator.extract( + ROOT_CONTEXT, + { "uber-trace-id": malformed }, + getter, + ); + assert.equal( + trace.getSpanContext(context), + undefined, + "malformed uber-trace-id must be ignored: " + malformed, + ); +} + +const baggageFromMalformedTrace = propagator.extract( + ROOT_CONTEXT, + { "uber-trace-id": "%ZZ", "uberctx-test": "value" }, + getter, +); +assert.equal(trace.getSpanContext(baggageFromMalformedTrace), undefined); +assert.equal(propagation.getBaggage(baggageFromMalformedTrace)?.getEntry("test")?.value, "value"); + +const validTraceWithMalformedBaggage = propagator.extract( + ROOT_CONTEXT, + { + "uber-trace-id": "d4cda95b652f4a1592b449d5929fda1b:6e0c63257de34c92:0:01", + "uberctx-bad": "%ZZ", + "uberctx-test": "value", + }, + getter, +); +assert.deepEqual(trace.getSpanContext(validTraceWithMalformedBaggage), { + traceId: "d4cda95b652f4a1592b449d5929fda1b", + spanId: "6e0c63257de34c92", + traceFlags: 1, + isRemote: true, +}); +const baggage = propagation.getBaggage(validTraceWithMalformedBaggage); +assert.equal(baggage?.getEntry("bad"), undefined); +assert.equal(baggage?.getEntry("test")?.value, "value"); + +console.log(JSON.stringify({ + jaegerVersion: packageJson.version, + malformedTraceHeaders: 3, + malformedBaggageIgnored: true, + validTracePreserved: true, +})); +`; + +function reviewedDiagnosticsPackage(): ReviewedPackage { + const config = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "ci", "reviewed-npm-audit.json"), "utf-8"), + ) as { archivePackages: ReviewedPackage[] }; + const reviewed = config.archivePackages.find(({ packageSpec }) => + packageSpec.startsWith("@openclaw/diagnostics-otel@"), + ); + assert.ok(reviewed, "reviewed npm audit config must include OpenClaw diagnostics"); + return reviewed; +} + +function requireSpawnSuccess( + result: ReturnType, + label: string, +): asserts result is ReturnType & { status: 0 } { + const detail = result.error?.message || result.stderr || result.stdout || "empty output"; + assert.equal(result.error, undefined, `${label} failed: ${detail}`); + assert.equal(result.status, 0, `${label} failed: ${detail}`); +} + +describe.skipIf(process.env[RUNTIME_HARNESS_ENV] !== "1")( + "OpenClaw diagnostics Jaeger runtime", + () => { + it( + "ignores malformed trace and baggage headers in the reviewed production graph (#7337)", + () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-jaeger-runtime-")); + try { + const home = path.join(workspace, "home"); + const cache = path.join(workspace, "npm-cache"); + fs.mkdirSync(home, { recursive: true, mode: 0o700 }); + fs.mkdirSync(cache, { recursive: true, mode: 0o700 }); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + NPM_CONFIG_AUDIT: "false", + NPM_CONFIG_CACHE: cache, + NPM_CONFIG_FUND: "false", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + NPM_CONFIG_UPDATE_NOTIFIER: "false", + NPM_CONFIG_USERCONFIG: "/dev/null", + }; + const reviewed = reviewedDiagnosticsPackage(); + const archive = packReviewedNpmArchive({ + env, + expectedIntegrity: reviewed.integrity, + label: reviewed.label, + packageSpec: reviewed.packageSpec, + tarballUrl: reviewed.tarballUrl, + tempDirectory: workspace, + }); + const productionArchive = remediateReviewedOpenClawPluginArchive({ + archivePath: archive.archivePath, + env, + packageSpec: reviewed.packageSpec, + workingDirectory: archive.rootDirectory, + }); + const runtime = path.join(workspace, "runtime"); + const install = spawnSync( + "npm", + [ + "install", + "--prefix", + runtime, + "--ignore-scripts", + "--no-audit", + "--no-fund", + productionArchive.archivePath, + ], + { encoding: "utf-8", env, timeout: RUNTIME_TIMEOUT_MS }, + ); + requireSpawnSuccess(install, "install reviewed diagnostics archive"); + + const diagnosticsRoot = path.join( + runtime, + "node_modules", + "@openclaw", + "diagnostics-otel", + ); + const probe = spawnSync( + process.execPath, + ["--input-type=module", "--eval", JAEGER_RUNTIME_PROBE, diagnosticsRoot], + { + encoding: "utf-8", + env: { HOME: home, NODE_OPTIONS: "", PATH: process.env.PATH }, + timeout: RUNTIME_TIMEOUT_MS, + }, + ); + requireSpawnSuccess(probe, "execute reviewed Jaeger runtime probe"); + expect(JSON.parse(probe.stdout)).toMatchObject({ + malformedBaggageIgnored: true, + malformedTraceHeaders: 3, + validTracePreserved: true, + }); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } + }, + RUNTIME_TIMEOUT_MS, + ); + }, +);