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 28a49db39a3..d55218dbec4 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: @@ -3491,7 +3491,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: @@ -3532,6 +3532,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" @@ -3548,6 +3559,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 @@ -3563,7 +3576,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: | @@ -3964,7 +3979,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/.github/workflows/main.yaml b/.github/workflows/main.yaml index ff6cdaba33d..6dcbc5c2fb2 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -129,7 +129,7 @@ jobs: cli-test-shards: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 strategy: fail-fast: false matrix: diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index a2f20092577..47830c7d165 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -292,7 +292,7 @@ jobs: needs: changes if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 strategy: fail-fast: false matrix: diff --git a/Dockerfile b/Dockerfile index 247026ed825..cd3254235f5 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 \ @@ -42,13 +42,19 @@ 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. The trusted managed-image staging +# path removes this one instruction when it has just built Dockerfile.base from +# the same Node image, avoiding a redundant 125 MB layer in that local-only case. +COPY --from=builder /usr/local/bin/node /usr/local/bin/node # 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 @@ -84,7 +90,7 @@ COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm -# 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 @@ -224,12 +230,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 @@ -290,14 +298,14 @@ 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 \ echo "ERROR: OpenClaw ${OPENCLAW_VERSION} has no committed npm integrity pin" >&2; exit 1; \ fi; \ OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle-v1'; \ - if [ "$OPENCLAW_VERSION" = "2026.3.11" ] || [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle+transitive-remediation-v1'; fi; \ + if [ "$OPENCLAW_VERSION" = "2026.3.11" ]; then OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle+transitive-remediation-v1'; fi; \ MCPORTER_EXPECTED_INTEGRITY=""; \ MCPORTER_EXPECTED_TARBALL=""; \ if [ "$MCPORTER_VERSION" = "0.7.3" ]; then MCPORTER_EXPECTED_INTEGRITY="$MCPORTER_0_7_3_INTEGRITY"; MCPORTER_EXPECTED_TARBALL="$MCPORTER_0_7_3_TARBALL"; fi; \ @@ -351,12 +359,13 @@ RUN set -eu; \ OPENCLAW_SOURCE_PACK_PATH="$(node --experimental-strip-types /scripts/lib/reviewed-npm-archive.mts \ --package-spec "openclaw@${OPENCLAW_VERSION}" --integrity "$EXPECTED_INTEGRITY" \ --tarball-url "$EXPECTED_TARBALL" --label "OpenClaw ${OPENCLAW_VERSION}")"; \ - OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_SOURCE_PACK_PATH")"; \ OPENCLAW_PACK_PATH="$OPENCLAW_SOURCE_PACK_PATH"; \ - if [ "$OPENCLAW_VERSION" = "2026.3.11" ] || [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then \ - OPENCLAW_PACK_PATH="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ + OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_PACK_PATH")"; \ + if [ "$OPENCLAW_VERSION" = "2026.3.11" ]; then \ + OPENCLAW_REMEDIATION_JSON="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ --archive "$OPENCLAW_SOURCE_PACK_PATH" --package-spec "openclaw@${OPENCLAW_VERSION}" \ --working-directory "$OPENCLAW_PACK_DIR")"; \ + OPENCLAW_PACK_PATH="$(node -e 'const value = JSON.parse(process.argv[1]); if (!value.remediated || typeof value.archivePath !== "string") process.exit(1); process.stdout.write(value.archivePath)' "$OPENCLAW_REMEDIATION_JSON")"; \ fi; \ # npm 10's atomic-move install can hit EROFS on overlayfs when the prior # install spans image layers. Removing it first also prevents unreviewed @@ -364,7 +373,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; \ @@ -372,7 +381,6 @@ RUN set -eu; \ fi; \ case "$OPENCLAW_VERSION" in \ 2026.3.11) npm ls -g --depth=1 openclaw tar >/dev/null ;; \ - 2026.6.10) npm ls -g --depth=1 openclaw @openclaw/fs-safe tar jszip >/dev/null ;; \ esac; \ if [ "$USE_REVIEWED_BASE_RUNTIME" = "1" ]; then \ echo "INFO: Reusing reviewed base mcporter $CUR_MCPORTER_VER with exact lock provenance"; \ @@ -561,7 +569,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 \ @@ -597,22 +605,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; \ @@ -621,7 +629,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 \ @@ -631,7 +639,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`. \ @@ -741,7 +749,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 @@ -757,7 +765,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. @@ -774,7 +782,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 @@ -798,12 +806,30 @@ 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 \ /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'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 +# 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). @@ -948,7 +974,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 @@ -1059,8 +1085,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; \ @@ -1072,17 +1098,19 @@ 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")"; \ plugin_install_archive="$plugin_archive"; \ case "$plugin_spec" in \ - "@openclaw/diagnostics-otel@2026.6.10") \ - plugin_install_archive="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ + "@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 "$(dirname "$plugin_archive")")" \ + --working-directory "$plugin_root")"; \ + plugin_install_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_install_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"; \ @@ -1309,14 +1337,39 @@ 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"; \ + 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" \ + "$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; }; \ + [ "$(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/Dockerfile.base b/Dockerfile.base index 3e67522663c..26f4186df9e 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. @@ -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 \ @@ -203,10 +206,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 @@ -228,7 +231,7 @@ 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 COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts -# npm 10.9.7 in the pinned Node 22 image bundles an affected node-tar copy. +# npm 10.9.8 in the pinned Node 22 image bundles an affected node-tar copy. # Replace only that private package from a registry- and SRI-verified archive # before npm processes any OpenClaw or mcporter installation input. RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ @@ -267,7 +270,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 \ @@ -279,18 +282,19 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep if [ -z "$OPENCLAW_SOURCE_PACK_PATH" ] || [ ! -f "$OPENCLAW_SOURCE_PACK_PATH" ] || [ -L "$OPENCLAW_SOURCE_PACK_PATH" ]; then \ echo "Error: reviewed OpenClaw archive path is empty or invalid"; exit 1; \ fi; \ - OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_SOURCE_PACK_PATH")"; \ OPENCLAW_PACK_PATH="$OPENCLAW_SOURCE_PACK_PATH"; \ + OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_PACK_PATH")"; \ OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle-v1'; \ - if [ "$OPENCLAW_VERSION" = "2026.3.11" ] || [ "$OPENCLAW_VERSION" = "2026.6.10" ]; then \ - OPENCLAW_PACK_PATH="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ + if [ "$OPENCLAW_VERSION" = "2026.3.11" ]; then \ + OPENCLAW_REMEDIATION_JSON="$(node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts \ --archive "$OPENCLAW_SOURCE_PACK_PATH" --package-spec "openclaw@${OPENCLAW_VERSION}" \ --working-directory "$OPENCLAW_PACK_DIR")"; \ + OPENCLAW_PACK_PATH="$(node -e 'const value = JSON.parse(process.argv[1]); if (!value.remediated || typeof value.archivePath !== "string") process.exit(1); process.stdout.write(value.archivePath)' "$OPENCLAW_REMEDIATION_JSON")"; \ OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle+transitive-remediation-v1'; \ fi; \ 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 \ @@ -301,7 +305,6 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep fi \ && case "$OPENCLAW_VERSION" in \ 2026.3.11) npm ls -g --depth=1 openclaw tar >/dev/null ;; \ - 2026.6.10) npm ls -g --depth=1 openclaw @openclaw/fs-safe tar jszip >/dev/null ;; \ esac \ && MCPORTER_EXPECTED_INTEGRITY="" \ && MCPORTER_EXPECTED_TARBALL="" \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index c731c5de6fc..c4a3a84b869 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -20,15 +20,6 @@ FROM node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c -COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts -COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts - -# npm 11.13.0 in the pinned Node 24 image bundles an affected node-tar copy. -# Patch that private package before npm processes the checksum-pinned Hermes -# release lockfiles. -RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ - --npm-root /usr/local/lib/node_modules/npm - ENV DEBIAN_FRONTEND=noninteractive # Hermes version pinned for reproducibility. All four values below are @@ -70,6 +61,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ vim-tiny=2:9.1.1230-2 \ && rm -rf /var/lib/apt/lists/* +COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts + +# npm 11.13.0 in the pinned Node 24 image bundles an affected node-tar copy. +# Patch that private package after curl is installed and before npm processes +# the checksum-pinned Hermes release lockfiles. +RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ + --npm-root /usr/local/lib/node_modules/npm + # gosu for privilege separation (gateway vs sandbox user). # Identical to OpenClaw base — pinned to 1.19 with checksum. # hadolint ignore=DL4006 diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index c68423ddabb..2b769a5b09c 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 COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -36,8 +36,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* # Node remains available to the managed terminal at runtime, so remediate -# npm's private node-tar copy even though Deep Agents Code itself is Python. -# The verified archive downloader is installed in the preceding layer. +# npm's private node-tar copy after curl is installed even though Deep Agents +# Code itself is Python. RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts \ --npm-root /usr/local/lib/node_modules/npm diff --git a/agents/openclaw/dependency-review.md b/agents/openclaw/dependency-review.md index 7980aeba13e..49abeff9949 100644 --- a/agents/openclaw/dependency-review.md +++ b/agents/openclaw/dependency-review.md @@ -15,11 +15,11 @@ 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 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 `13` packages. -- Security override: `@hono/node-server@2.0.11` (`sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==`) replaces the SDK's vulnerable `1.19.14` resolution for `GHSA-frvp-7c67-39w9` and the previously reviewed `2.0.5` resolution affected by `GHSA-9mqv-5hh9-4cgg`. `2.0.5` is the first patched release for `GHSA-frvp-7c67-39w9`. The reviewed v2 range retains the `getRequestListener` API used by `@modelcontextprotocol/sdk`; its Node.js 20 floor is below NemoClaw's Node.js 22.19 floor, and the `/vercel` adapter is not consumed. Mcporter's production path imports the SDK's client transport, not the server adapter, and the image build still exercises the installed CLI after the locked install. -- Security override: `fast-uri@3.1.4` (`sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==`) replaces Ajv's vulnerable `3.1.3` resolution for `GHSA-v2hh-gcrm-f6hx`. It remains within Ajv's declared `^3.0.1` range and preserves the reviewed v3 API boundary. +- Security override: `@hono/node-server@2.0.11` (`sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==`) replaces the SDK's vulnerable `1.19.14` resolution for `GHSA-frvp-7c67-39w9` and the previously reviewed `2.0.5` resolution affected by `GHSA-9mqv-5hh9-4cgg`. `2.0.5` is the first patched release for `GHSA-frvp-7c67-39w9`. The reviewed v2 range retains the `getRequestListener` API used by `@modelcontextprotocol/sdk`; its Node.js 20 floor is below NemoClaw's Node.js 22.19 floor, and the `/vercel` adapter is not consumed. Mcporter's production path imports the SDK's client transport, not the server adapter, and the image build still exercises the installed CLI after the locked install. Remove the override when the SDK's declared range resolves to a reviewed release outside both affected ranges. +- Security override: `fast-uri@3.1.4` (`sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==`) replaces Ajv's vulnerable `3.1.3` resolution for `GHSA-v2hh-gcrm-f6hx`. It remains within Ajv's declared `^3.0.1` range and preserves the reviewed v3 API boundary. Remove the override when the declared graph 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/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/full-e2e-cold-path-calibration.json b/ci/full-e2e-cold-path-calibration.json index 5d50ccb4c8b..5f638ae2aa9 100644 --- a/ci/full-e2e-cold-path-calibration.json +++ b/ci/full-e2e-cold-path-calibration.json @@ -1,5 +1,5 @@ { - "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n\nFive independent workflow_dispatch full-e2e samples of current-main baseline 1a74b8348c2182fbf806726341186f008444e28e with the phase-measurement changes at 4544d07c8bfd500c3b64a74380ef5cd0e62089f5. All runs completed install, BuildKit prebuild without fallback, the silence assertion, and the expected first turn. Each baseline budget is derived independently so phase caps diagnose regressions; they are not portions that must sum to the root-start budget. The separate validation adjustment records four existing full-e2e job observations from three descendant heads after f62c278bd737f4f47be2e85436f65b270d5b4280 added the reviewed WeChat runtime graph to the supported sandbox image. The relevant image-building inputs listed in imageInputPaths were unchanged through 2adc8481ff3053a5a7be37d130cb183e222934ff; repository tests enforce that ancestry and unchanged-input boundary. All four jobs completed install, BuildKit prebuild without fallback, the silence assertion, and the expected first turn; two exceeded the prior root-start and/or sandbox-phase caps. The conclusion fields record the full-e2e job conclusions, not aggregate workflow conclusions. The adjustment raises only those two caps from the maximum observed value plus the existing headroom, rounded up to one second. This is a bounded post-change validation adjustment, not a replacement five-run single-SHA calibration. Retire it by replacing the baseline and removing validationAdjustment after five successful full-e2e samples from one SHA that contains the image change. The authoritative-local-build adjustment records two functional same-head PR runs that emitted the required local base-build reason. Its allowance is the maximum excess over the normal root-start or sandbox-phase budget plus the larger of 5 seconds or 10 percent, rounded up to one second. It applies only when that reason is observed and does not alter published-image budgets.", + "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n\nFive independent workflow_dispatch full-e2e samples of current-main baseline 1a74b8348c2182fbf806726341186f008444e28e with the phase-measurement changes at 4544d07c8bfd500c3b64a74380ef5cd0e62089f5. All runs completed install, BuildKit prebuild without fallback, the silence assertion, and the expected first turn. Each baseline budget is derived independently so phase caps diagnose regressions; they are not portions that must sum to the root-start budget. The separate validation adjustment records four existing full-e2e job observations from three descendant heads after f62c278bd737f4f47be2e85436f65b270d5b4280 added the reviewed WeChat runtime graph to the supported sandbox image. The relevant image-building inputs listed in imageInputPaths were unchanged through 2adc8481ff3053a5a7be37d130cb183e222934ff; repository tests enforce that ancestry and unchanged-input boundary. All four jobs completed install, BuildKit prebuild without fallback, the silence assertion, and the expected first turn; two exceeded the prior root-start and/or sandbox-phase caps. The conclusion fields record the full-e2e job conclusions, not aggregate workflow conclusions. The adjustment raises only those two caps from the maximum observed value plus the existing headroom, rounded up to one second. This is a bounded post-change validation adjustment, not a replacement five-run single-SHA calibration. Retire it by replacing the baseline and removing validationAdjustment after five successful full-e2e samples from one SHA that contains the image change. The authoritative-local-build adjustment records two functional same-head PR runs that emitted the required local base-build reason. Its allowance is the maximum excess over the normal root-start or sandbox-phase budget plus an operator-selected stability floor of 27 seconds (or 10 percent, if larger), rounded up to one second. It applies only when that reason is observed and does not alter published-image budgets.", "schemaVersion": 1, "calibratedAt": "2026-07-13", "baselineMainSha": "1a74b8348c2182fbf806726341186f008444e28e", @@ -336,27 +336,27 @@ ], "derivation": { "statistic": "maximum-budget-excess", - "minimumHeadroomMs": 5000, + "minimumHeadroomMs": 27000, "relativeHeadroomPercent": 10, "roundUpMs": 1000 }, "runs": [ { - "runId": 29806262836, - "runUrl": "https://github.com/NVIDIA/NemoClaw/actions/runs/29806262836", - "headSha": "6752f3e7918d588f53d5a3d3ebd37b8e7bf7aaab", - "rootStartToFirstTurnCompletionMs": 247993, - "sandboxPhaseMs": 196679 + "runId": 29871968836, + "runUrl": "https://github.com/NVIDIA/NemoClaw/actions/runs/29871968836", + "headSha": "188d9a75b3e5efdafeb38e885138bb196197574f", + "rootStartToFirstTurnCompletionMs": 277769, + "sandboxPhaseMs": 225927 }, { - "runId": 29806391548, - "runUrl": "https://github.com/NVIDIA/NemoClaw/actions/runs/29806391548", - "headSha": "6752f3e7918d588f53d5a3d3ebd37b8e7bf7aaab", - "rootStartToFirstTurnCompletionMs": 248833, - "sandboxPhaseMs": 196916 + "runId": 29872724335, + "runUrl": "https://github.com/NVIDIA/NemoClaw/actions/runs/29872724335", + "headSha": "188d9a75b3e5efdafeb38e885138bb196197574f", + "rootStartToFirstTurnCompletionMs": 281762, + "sandboxPhaseMs": 233584 } ], - "derivedAllowanceMs": 31000 + "derivedAllowanceMs": 90000 }, "derivedBudgetsMs": { "rootStartToFirstTurnCompletionBudgetMs": 205000, diff --git a/ci/onboard-performance-budget.json b/ci/onboard-performance-budget.json index a0f93bb0fe9..cdfeb1968fa 100644 --- a/ci/onboard-performance-budget.json +++ b/ci/onboard-performance-budget.json @@ -13,7 +13,7 @@ "minPercent": 30 }, "fullE2eColdPath": { - "authoritativeLocalBaseBuildAllowanceMs": 31000, + "authoritativeLocalBaseBuildAllowanceMs": 90000, "rootStartToFirstTurnCompletionBudgetMs": 228000, "rootEndToFirstTurnCompletionBudgetMs": 14000, "phaseBudgetsMs": { 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/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index f6853a5c9a1..02e52ccc642 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -327,33 +327,38 @@ "category": "compatibility" }, { - "file": "test/openclaw-dependency-review.test.ts", - "test": "runs and gates the real patched-distribution harness only from trusted main code", + "file": "test/openclaw-2026-6-npm-remediation.test.ts", + "test": "rebuilds a guarded core archive with the patched fs-safe package bundled", "category": "security" }, { - "file": "test/openclaw-lifecycle-policy.test.ts", - "test": "cross-checks the allowlist against every production archive install boundary", + "file": "test/openclaw-2026-6-npm-remediation.test.ts", + "test": "rebuilds a guarded plugin archive with the patched Axios graph bundled", "category": "security" }, { - "file": "test/openclaw-npm-remediation.test.ts", - "test": "rebuilds a guarded core archive with the patched fs-safe package bundled", + "file": "test/openclaw-2026-6-npm-remediation.test.ts", + "test": "replaces the reviewed bundled Axios graph with the patched graph", "category": "security" }, { - "file": "test/openclaw-npm-remediation.test.ts", - "test": "rebuilds a guarded plugin archive with the patched Axios graph bundled", + "file": "test/openclaw-2026-6-npm-remediation.test.ts", + "test": "replaces the reviewed OpenClaw core tar and brace-expansion graph", "category": "security" }, { - "file": "test/openclaw-npm-remediation.test.ts", - "test": "replaces the reviewed bundled Axios graph with the patched graph", + "file": "test/openclaw-dependency-review.test.ts", + "test": "copies the legacy OpenClaw remediation helper before the base build invokes it", "category": "security" }, { - "file": "test/openclaw-npm-remediation.test.ts", - "test": "replaces the reviewed OpenClaw core tar and brace-expansion graph", + "file": "test/openclaw-dependency-review.test.ts", + "test": "runs and gates the real patched-distribution harness only from trusted main code", + "category": "security" + }, + { + "file": "test/openclaw-lifecycle-policy.test.ts", + "test": "cross-checks the allowlist against every production archive install boundary", "category": "security" }, { diff --git a/docs/security/openclaw-2026.6.10-dependency-review.md b/docs/security/openclaw-2026.6.10-dependency-review.md index bb0e9518718..061e4870641 100644 --- a/docs/security/openclaw-2026.6.10-dependency-review.md +++ b/docs/security/openclaw-2026.6.10-dependency-review.md @@ -77,7 +77,15 @@ The OpenClaw 2026.6.10 bump does not newly introduce an unfrozen OpenClaw transi ### Transitive Remediation Boundary -`scripts/lib/openclaw-npm-remediation.mts` remediates only five exact reviewed identities. +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 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. + +`scripts/lib/openclaw-npm-remediation.mts` recognizes only eight exact reviewed identities: the E2E-only 2026.3.11 core archive, four retained 2026.6.10 identities, and three active 2026.7.1 identities. It rejects an unexpected source dependency shape before it changes or installs an archive. The helper verifies every replacement package by exact registry SRI and tarball URL. It also rejects unsafe archive members before extraction and after repacking. @@ -123,7 +131,7 @@ The replacement packages are bound to these registry identities: | `@opentelemetry/propagator-jaeger@2.9.0` | `sha512-4mYGty27rYvSM0jtp1ZUOqd3LfVRCYg9H5G9OFzSx5HViYToU21MFhWfco7x1HwXr7ER8yGOiCIHZUwjPksc0Q==` | `https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.9.0.tgz` | | `@opentelemetry/core@2.9.0` | `sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==` | `https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz` | -The helper extracts and rebuilds archives with `tar` instead of invoking a package build or pack lifecycle, and it disables npm lifecycle scripts while retrieving replacement archives. +The helper extracts reviewed archives without invoking package lifecycle scripts and rebuilds them with `npm pack . --ignore-scripts --json`. It binds each patched package manifest and shrinkwrap to a committed SHA-512 metadata value. The core value also covers the bundled `@openclaw/fs-safe` package manifest. The diagnostics value also covers the bundled SDK, Jaeger propagator, and nested core package manifests. @@ -257,13 +265,13 @@ The reviewed `@openclaw/diagnostics-otel@2026.6.10` package dist imports `OTLPTr The legacy `2026.3.11` and `2026.4.24` OpenClaw pins are retained only for stale-upgrade fixture builds. Production Dockerfile install blocks now reject those versions unless `NEMOCLAW_E2E_FIXTURE_LEGACY_OPENCLAW=1` is set explicitly. The E2E-scoped name is intentionally noisy so production build workflows do not treat it as a general override. Production image workflows run `scripts/check-production-build-args.sh` before production Docker builds so the fixture flag, both legacy version values, and every integrity/tarball Docker ARG declared by a production Dockerfile cannot be overridden through production build args or their corresponding environment variables. The guard also rejects future positional `*_INTEGRITY` and `*_TARBALL` names, keeping reviewed pin values repository-controlled even before the Dockerfile's registry and downloaded-archive checks run. The stale-upgrade E2E build contexts pass their fixture values only on fixture-specific build paths, and the integrity-pin contract suite verifies the default rejection, the explicit fixture opt-in, and the production workflow guard. -Frozen OpenShell gateway-upgrade fixtures select only the SRI-pinned OpenClaw `2026.4.24`, `2026.5.22`, or `2026.5.27` archive. +Frozen OpenShell gateway-upgrade fixtures select only the SRI-pinned OpenClaw `2026.4.24`, `2026.5.22`, `2026.5.27`, or `2026.6.10` archive. The live test uses `packReviewedNpmArchive` to verify exact registry metadata, the reviewed tarball URL, and the downloaded SRI. The adapter stores only that verified local archive at `nemoclaw/src/.nemoclaw-e2e-old-openclaw.tgz`, which the frozen optimized build-context staging preserves. It installs the archive with lifecycle scripts disabled and invokes `postinstall-bundled-plugins.mjs` directly. -The `v0.0.74` fixture retains npm registry signature verification for its historical mcporter lock. +The `v0.0.74` and `v0.0.89` fixtures retain npm registry signature verification for their historical mcporter locks. The reviewed `v0.0.36` and `v0.0.55` profiles require no advisory audit statement. -The reviewed `v0.0.74` profile requires exactly one advisory audit statement, which the adapter replaces with a test-only skip. +The reviewed `v0.0.74` and `v0.0.89` profiles require exactly one advisory audit statement, which the adapter replaces with a test-only skip. Each audit policy is bound to one exact reviewed NemoClaw tag, full commit SHA, and OpenClaw version tuple before the installer is patched. The adapter rejects an unknown or mixed tuple and any advisory audit count that does not match its reviewed profile. `test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts` verifies these constraints. 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..3beaceb88cd --- /dev/null +++ b/docs/security/openclaw-2026.7.1-dependency-review.md @@ -0,0 +1,292 @@ + + + +# OpenClaw 2026.7.1 dependency review + +Review date: 2026-07-21 + +## 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. 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 +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 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 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` +- `@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 dependency remediations + +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`. + +The separately locked `mcporter@0.7.3` runtime graph originally resolved +`@hono/node-server@1.19.14`, affected by `GHSA-frvp-7c67-39w9`. Its former +`2.0.5` override became affected when `GHSA-9mqv-5hh9-4cgg` was published for +releases through `2.0.9`. The locked `@modelcontextprotocol/sdk@1.29.0` still +declares `@hono/node-server@^1.19.9`, so the dedicated runtime manifest now pins +reviewed `2.0.11`, outside both affected ranges. 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 SDK's locked AJV graph also requests `fast-uri@^3.0.1`; the newly published +`GHSA-v2hh-gcrm-f6hx` affects releases through `3.1.3`. The same manifest pins +the first compatible release outside that range, `3.1.4`. The resulting +`138`-dependency graph reports `0` known vulnerabilities; image assembly keeps +the low audit threshold, signature verification, and exact committed lock +mandatory. Remove either override when the declared graph 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 +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==`. + +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 +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. + +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 +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 + +`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 seven 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. 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 + `operator.pairing`, `operator.read`, and `operator.write` into that gate; the + 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 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; +- 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 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. 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 +the canonical `approveDevicePairing` transaction fail closed. +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. Once that credential exists, the patch automatically +retains CLI identity on ordinary loopback shared-token calls; the upstream +local-backend omission remains unchanged. This restores device-scope +enforcement without moving the gateway credential into OpenClaw state. 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 + +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. + +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 durable marker in the per-agent database materialized +by the legacy CLI, then upgrades through the current installer. The row proves +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` 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 +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 +`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 +`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 0480ec834ea..9e11c1df1ea 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 b2c0317e940..43dadc0f452 100755 --- a/scripts/audit-reviewed-npm-graph.mts +++ b/scripts/audit-reviewed-npm-graph.mts @@ -7,7 +7,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { remediateReviewedOpenClawArchive } from "./lib/openclaw-npm-remediation.mts"; +import { remediateReviewedOpenClawPluginArchive } from "./lib/openclaw-npm-remediation.mts"; import { packReviewedNpmArchive, verifyReviewedNpmMetadata } from "./lib/reviewed-npm-archive.mts"; type Severity = "info" | "low" | "moderate" | "high" | "critical"; @@ -144,7 +144,7 @@ function materializeArchiveGraph(packages: readonly ReviewedPackage[], tempRoot: tarballUrl: reviewed.tarballUrl, tempDirectory: tempRoot, }); - return remediateReviewedOpenClawArchive({ + return remediateReviewedOpenClawPluginArchive({ archivePath: archive.archivePath, packageSpec: reviewed.packageSpec, workingDirectory: archive.rootDirectory, 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/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/lib/openclaw-npm-remediation.mts b/scripts/lib/openclaw-npm-remediation.mts index 5ac6a4afa26..e9012a23f70 100755 --- a/scripts/lib/openclaw-npm-remediation.mts +++ b/scripts/lib/openclaw-npm-remediation.mts @@ -5,23 +5,30 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { + closeSync, + constants, cpSync, existsSync, + fstatSync, mkdirSync, mkdtempSync, + openSync, + readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +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<{ - kind: "core" | "diagnostics-otel" | "legacy-core" | "plugin"; - expectedPatchedMetadataIntegrity: string; + expectedPatchedMetadataIntegrity?: string; + expectedPatchedTreeIntegrity?: string; + kind: "axios" | "core" | "jaeger" | "legacy-core"; + version: "2026.3.11" | "2026.6.10" | "2026.7.1"; }>; type RemediationRequest = Readonly<{ @@ -34,6 +41,7 @@ type RemediationRequest = Readonly<{ type BuildRequest = RemediationRequest & Readonly<{ expectedPatchedMetadataIntegrity?: string; + expectedPatchedTreeIntegrity?: string; }>; export type RemediatedArchive = Readonly< @@ -47,6 +55,7 @@ export type RemediatedArchive = Readonly< integrity: string; metadataIntegrity: string; remediated: true; + treeIntegrity: string; } >; @@ -76,10 +85,10 @@ const BRACE_EXPANSION_INTEGRITY = "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="; const BRACE_EXPANSION_TARBALL = "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz"; -const OTEL_PROPAGATOR_JAEGER_VERSION = "2.9.0"; -const OTEL_PROPAGATOR_JAEGER_INTEGRITY = +const JAEGER_PROPAGATOR_VERSION = "2.9.0"; +const JAEGER_PROPAGATOR_INTEGRITY = "sha512-4mYGty27rYvSM0jtp1ZUOqd3LfVRCYg9H5G9OFzSx5HViYToU21MFhWfco7x1HwXr7ER8yGOiCIHZUwjPksc0Q=="; -const OTEL_PROPAGATOR_JAEGER_TARBALL = +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 = @@ -88,29 +97,52 @@ const OTEL_CORE_TARBALL = "https://registry.npmjs.org/@opentelemetry/core/-/core const REMEDIATIONS: Readonly> = Object.freeze({ "@openclaw/diagnostics-otel@2026.6.10": { - kind: "diagnostics-otel", expectedPatchedMetadataIntegrity: "sha512-ByLYBs3KXz3u0mPuj9DcP/xPTJNgQaLTPxazybhyIC1VjyftEmKQuoZufPZ8z8CjwBsOPm6NbjMQB2BfX36TTg==", + kind: "jaeger", + version: "2026.6.10", }, "@openclaw/msteams@2026.6.10": { - kind: "plugin", expectedPatchedMetadataIntegrity: "sha512-eTTIpA8HzcBwXBLt6UZDoFgOUmkRgIhcZFBOwg+5Jfgt8HDwtfPnqKo6vm2DdDdPMPhu08FbEzU5Gt3RoL5fIw==", + kind: "axios", + version: "2026.6.10", }, "@openclaw/slack@2026.6.10": { - kind: "plugin", expectedPatchedMetadataIntegrity: "sha512-AXllGzI+m33jUq3w1nCVXngLA1m9kH8c9XryHSoPzuVhGP6xwWpzgKl3yyfOMoIykN0GKcka59ZZbjEwkxFudQ==", + kind: "axios", + version: "2026.6.10", + }, + "@openclaw/diagnostics-otel@2026.7.1": { + expectedPatchedTreeIntegrity: + "sha512-2qyDTRPqNs97jo/pAWWfxAkVZyCXYqui/IjrGf4eEfYop1eGN8qBMJ/Kp/bJ/V18RNnYpMxHi5ECFelekVxcAQ==", + kind: "jaeger", + version: "2026.7.1", + }, + "@openclaw/msteams@2026.7.1": { + expectedPatchedTreeIntegrity: + "sha512-FL4l65gEbbwtDd9Ogr69+xBNzIfE4YS8Hib36G+kcmX+T0oB1zL+/qs6b4bJc+ygTsh60H3yqpFbXoQeN05JYQ==", + kind: "axios", + version: "2026.7.1", + }, + "@openclaw/slack@2026.7.1": { + expectedPatchedTreeIntegrity: + "sha512-4ThnsNS+yBlFSkTaQn2xosxrDu1s0vrxcqka5QqFj+8dCEaTa9JVLRgNniYV/QNhO53wc7a2R5oQFElzYspT2w==", + kind: "axios", + version: "2026.7.1", }, "openclaw@2026.6.10": { - kind: "core", expectedPatchedMetadataIntegrity: "sha512-B5O6Gu3YGY52w+Px8diL5zBtk8mj0u7E1ZvVK7KOLWX9H+S3B7kYUxnGfyB239mVYSluecfiWGvFFMk5eFhwKg==", + kind: "core", + version: "2026.6.10", }, "openclaw@2026.3.11": { kind: "legacy-core", expectedPatchedMetadataIntegrity: "sha512-1i30XSb/2NEcuTcuhXfR/x3YKaXVhWq6ttecFBSD9nrCKrzjNxSNMfK1y3qRcnblNOzRWmHtJZwZKeej02s/EQ==", + version: "2026.3.11", }, }); @@ -188,6 +220,40 @@ function writeJson(path: string, value: JsonObject): void { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); } +// 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 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"); + } else { + throw new Error(`Remediated package tree has unsupported entry ${relativePath}`); + } + } finally { + closeSync(descriptor); + } + } + }; + visit(packageDirectory, ""); + return `sha512-${hash.digest("base64")}`; +} + function hashMetadataEntries(entries: readonly (readonly [string, Buffer])[]): string { const hash = createHash("sha512"); for (const [name, contents] of entries) { @@ -198,6 +264,9 @@ function hashMetadataEntries(entries: readonly (readonly [string, Buffer])[]): s return `sha512-${hash.digest("base64")}`; } +// The retained 2026.6.10 remediation shipped with a narrower metadata digest. +// Keep enforcing that exact historical contract for those four identities while +// 2026.7.1 continues to use the stronger complete-tree digest above. function hashPatchedMetadata(packageDirectory: string): string { const packageJson = readJson(join(packageDirectory, "package.json")); if (packageJson.name === "openclaw" && packageJson.version === "2026.3.11") { @@ -457,6 +526,7 @@ export function patchLegacyOpenClawCorePackageGraph(packageDirectory: string): v } export function patchOpenClawDiagnosticsPackageGraph(packageDirectory: string): void { + const packageSpec = "@openclaw/diagnostics-otel@2026.6.10"; const packageJsonPath = join(packageDirectory, "package.json"); const shrinkwrapPath = join(packageDirectory, "npm-shrinkwrap.json"); const sdkPackageJsonPath = join( @@ -478,16 +548,12 @@ export function patchOpenClawDiagnosticsPackageGraph(packageDirectory: string): !Array.isArray(packageJson.bundledDependencies) || !packageJson.bundledDependencies.includes("@opentelemetry/sdk-node") ) { - throw new Error( - "@openclaw/diagnostics-otel@2026.6.10 SDK bundle changed; review the remediation", - ); + throw new Error(`${packageSpec} SDK bundle changed; review the remediation`); } const shrinkwrap = readJson(shrinkwrapPath); if (shrinkwrap.lockfileVersion !== 3 || !shrinkwrap.packages?.[""]) { - throw new Error( - "@openclaw/diagnostics-otel@2026.6.10 must ship an npm lockfileVersion 3 shrinkwrap", - ); + throw new Error(`${packageSpec} must ship an npm lockfileVersion 3 shrinkwrap`); } const packages = shrinkwrap.packages as JsonObject; const sdk = packages["node_modules/@opentelemetry/sdk-node"] as JsonObject | undefined; @@ -503,9 +569,7 @@ export function patchOpenClawDiagnosticsPackageGraph(packageDirectory: string): jaeger.dependencies?.["@opentelemetry/core"] !== "2.8.0" || packages[nestedCoreKey] !== undefined ) { - throw new Error( - "@openclaw/diagnostics-otel@2026.6.10 Jaeger graph changed; review the remediation", - ); + throw new Error(`${packageSpec} Jaeger graph changed; review the remediation`); } const sdkPackageJson = readJson(sdkPackageJsonPath); @@ -521,12 +585,12 @@ export function patchOpenClawDiagnosticsPackageGraph(packageDirectory: string): ); } - sdk.dependencies["@opentelemetry/propagator-jaeger"] = OTEL_PROPAGATOR_JAEGER_VERSION; - sdkPackageJson.dependencies["@opentelemetry/propagator-jaeger"] = OTEL_PROPAGATOR_JAEGER_VERSION; + sdk.dependencies["@opentelemetry/propagator-jaeger"] = JAEGER_PROPAGATOR_VERSION; + sdkPackageJson.dependencies["@opentelemetry/propagator-jaeger"] = JAEGER_PROPAGATOR_VERSION; packages["node_modules/@opentelemetry/propagator-jaeger"] = { - version: OTEL_PROPAGATOR_JAEGER_VERSION, - resolved: OTEL_PROPAGATOR_JAEGER_TARBALL, - integrity: OTEL_PROPAGATOR_JAEGER_INTEGRITY, + 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" }, @@ -547,6 +611,70 @@ export function patchOpenClawDiagnosticsPackageGraph(packageDirectory: string): 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 patchFsSafePackageGraph(packageDirectory: string): void { const packageJsonPath = join(packageDirectory, "package.json"); const packageJson = readJson(packageJsonPath); @@ -594,7 +722,7 @@ function packReplacement( }); } -export function buildRemediatedOpenClawArchive( +export function buildRemediatedOpenClawPluginArchive( request: BuildRequest, ): Extract { const remediation = REMEDIATIONS[request.packageSpec]; @@ -665,66 +793,7 @@ export function buildRemediatedOpenClawArchive( ); copyReplacementPackage(tarPackage, bundledTarPath); patchLegacyOpenClawCorePackageGraph(sourcePackage); - } else if (remediation.kind === "diagnostics-otel") { - const jaegerArchive = packReplacement( - `@opentelemetry/propagator-jaeger@${OTEL_PROPAGATOR_JAEGER_VERSION}`, - OTEL_PROPAGATOR_JAEGER_INTEGRITY, - OTEL_PROPAGATOR_JAEGER_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, "opentelemetry-propagator-jaeger"), - remediationRoot, - env, - ); - const corePackage = extractArchive( - coreArchive.archivePath, - join(remediationRoot, "opentelemetry-core"), - remediationRoot, - env, - ); - const jaegerPackageJson = readJson(join(jaegerPackage, "package.json")); - const corePackageJson = readJson(join(corePackage, "package.json")); - requirePackageIdentity( - jaegerPackageJson, - "@opentelemetry/propagator-jaeger", - OTEL_PROPAGATOR_JAEGER_VERSION, - "OpenTelemetry Jaeger remediation package", - ); - requirePackageIdentity( - corePackageJson, - "@opentelemetry/core", - OTEL_CORE_VERSION, - "OpenTelemetry core remediation package", - ); - requireDependencyShape( - jaegerPackageJson, - { "@opentelemetry/core": OTEL_CORE_VERSION }, - `@opentelemetry/propagator-jaeger@${OTEL_PROPAGATOR_JAEGER_VERSION}`, - ); - requireDependencyShape( - corePackageJson, - { "@opentelemetry/semantic-conventions": "^1.29.0" }, - `@opentelemetry/core@${OTEL_CORE_VERSION}`, - ); - - patchOpenClawDiagnosticsPackageGraph(sourcePackage); - const jaegerTarget = join(sourcePackage, "node_modules", "@opentelemetry", "propagator-jaeger"); - copyReplacementPackage(jaegerPackage, jaegerTarget); - copyReplacementPackage( - corePackage, - join(jaegerTarget, "node_modules", "@opentelemetry", "core"), - ); - } else { + } else if (remediation.kind === "axios") { const axiosArchive = packReplacement( `axios@${AXIOS_VERSION}`, AXIOS_INTEGRITY, @@ -808,26 +877,122 @@ export function buildRemediatedOpenClawArchive( 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"), + ); + if (remediation.version === "2026.6.10") { + patchOpenClawDiagnosticsPackageGraph(sourcePackage); + } else { + patchOpenClawDiagnosticsOtelPackageGraph(sourcePackage); + } } const outputDirectory = join(remediationRoot, "output"); mkdirSync(outputDirectory, { recursive: true, mode: 0o700 }); - const archivePath = join(outputDirectory, "openclaw-remediated.tgz"); - run("tar", ["-czf", archivePath, "-C", dirname(sourcePackage), "package"], remediationRoot, env); + 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 packedPackage = extractArchive( + archivePath, + join(remediationRoot, "packed-output"), + remediationRoot, + env, + ); const metadataIntegrity = hashPatchedMetadata(sourcePackage); + const treeIntegrity = hashPackageTree(packedPackage); const integrity = `sha512-${createHash("sha512").update(readFileSync(archivePath)).digest("base64")}`; const expectedPatchedMetadataIntegrity = request.expectedPatchedMetadataIntegrity ?? remediation.expectedPatchedMetadataIntegrity; - if (metadataIntegrity !== expectedPatchedMetadataIntegrity) { + if (expectedPatchedMetadataIntegrity && metadataIntegrity !== expectedPatchedMetadataIntegrity) { throw new Error( `Remediated ${request.packageSpec} metadata integrity mismatch: expected ${expectedPatchedMetadataIntegrity}, got ${metadataIntegrity}`, ); } - return { archivePath, integrity, metadataIntegrity, remediated: true }; + const expectedPatchedTreeIntegrity = + request.expectedPatchedTreeIntegrity ?? remediation.expectedPatchedTreeIntegrity; + if (expectedPatchedTreeIntegrity && treeIntegrity !== expectedPatchedTreeIntegrity) { + throw new Error( + `Remediated ${request.packageSpec} tree integrity mismatch: expected ${expectedPatchedTreeIntegrity}, got ${treeIntegrity}`, + ); + } + return { archivePath, integrity, metadataIntegrity, remediated: true, treeIntegrity }; +} + +// Compatibility export for the 2026.6.10 remediation tests and callers merged +// from main. Both names use the same version-dispatched implementation. +export function buildRemediatedOpenClawArchive( + request: BuildRequest, +): Extract { + return buildRemediatedOpenClawPluginArchive(request); } -export function remediateReviewedOpenClawArchive(request: RemediationRequest): RemediatedArchive { +export function remediateReviewedOpenClawPluginArchive( + request: RemediationRequest, +): RemediatedArchive { const remediation = REMEDIATIONS[request.packageSpec]; if (!remediation) { return { @@ -838,9 +1003,10 @@ export function remediateReviewedOpenClawArchive(request: RemediationRequest): R remediated: false, }; } - return buildRemediatedOpenClawArchive({ + return buildRemediatedOpenClawPluginArchive({ ...request, expectedPatchedMetadataIntegrity: remediation.expectedPatchedMetadataIntegrity, + expectedPatchedTreeIntegrity: remediation.expectedPatchedTreeIntegrity, }); } @@ -857,12 +1023,15 @@ if (isMainModule()) { return result; }; try { - const remediated = buildRemediatedOpenClawArchive({ - archivePath: value("--archive"), - packageSpec: value("--package-spec"), - workingDirectory: value("--working-directory"), - }); - console.log(remediated.archivePath); + console.log( + JSON.stringify( + remediateReviewedOpenClawPluginArchive({ + 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..74d993d7d29 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 @@ -2513,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 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 @@ -2636,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. @@ -2848,16 +2876,14 @@ def brief_child_error(out, err): return (lines[-1] if lines else '')[:400] # 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.6.10 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): +# runtime environment, so its first list call resolves the live gateway through +# local loopback and retains the shared token plus a private child marker. The +# reviewed 2026.7.1 dist patch uses that marker to retain CLI identity before a +# stored device credential exists. Once OpenClaw issues that credential, the +# patch retains identity for ordinary loopback CLI calls automatically. 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 @@ -2865,6 +2891,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, @@ -2912,7 +2941,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') + 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 ( @@ -2935,6 +2971,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: @@ -3363,6 +3402,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" @@ -3452,7 +3498,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 @@ -4833,7 +4879,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=$! @@ -5336,6 +5382,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. @@ -5356,6 +5413,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-bundled-npm-tar.mts b/scripts/patch-bundled-npm-tar.mts index e8e180d448f..45439d2410d 100755 --- a/scripts/patch-bundled-npm-tar.mts +++ b/scripts/patch-bundled-npm-tar.mts @@ -39,7 +39,7 @@ export const MINIMUM_SAFE_TAR_VERSION = "7.5.19"; * The Dockerfile contract test forces that review whenever either pin changes. */ export const NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH = [ - "node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d", + "node:22-trixie-slim@sha256:e6d9a389d34ff9678438af985c9913fbd1eb6ed36e80fea56644f4b4f6dd70ba", "node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c", ] as const; diff --git a/scripts/patch-openclaw-chat-send.mts b/scripts/patch-openclaw-chat-send.mts index 9acdc42facd..939f529c545 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. @@ -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 { @@ -229,7 +245,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 +261,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..5ba994a0874 100644 --- a/scripts/patch-openclaw-device-self-approval.mts +++ b/scripts/patch-openclaw-device-self-approval.mts @@ -2,19 +2,26 @@ // 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 - * 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 - * approveDevicePairing path reload, lock, rotate the token, persist, broadcast, - * and respond. + * 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. OpenClaw 2026.7.1 also omits CLI identity for loopback shared-token + * calls. Preserve that identity whenever a stored operator device credential + * exists, while retaining the upstream omission during bootstrap and for the + * local backend. The shared token may authenticate the connection, but the + * signed identity keeps the paired-device scope check authoritative and lets + * OpenClaw create a canonical pending scope-upgrade request. Keep the entire + * approval in OpenClaw instead: 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. * * Remove this patch when upstream OpenClaw supports same-device, operator-only * scope approval through the gateway using the already-approved pairing scope @@ -35,6 +42,9 @@ 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 CALL_STORED_IDENTITY_MARKER = + "nemoclaw: retain stored CLI device identity for loopback shared-token scope enforcement"; const CLI_APPLIED_MARKERS = [ CLI_MARKER, CLI_APPROVE_MARKER, @@ -42,6 +52,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"; @@ -57,6 +69,26 @@ 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"); +const CALL_STORED_IDENTITY_TARGET = + "\tconst isLocalCliSharedAuth = mode === GATEWAY_CLIENT_MODES.CLI && clientName === GATEWAY_CLIENT_NAMES.CLI && hasSharedSecretAuth && isLoopback;"; +const CALL_STORED_IDENTITY_REPLACEMENT = [ + "\tconst isLocalCliSharedAuth =", + "\t\tmode === GATEWAY_CLIENT_MODES.CLI &&", + "\t\tclientName === GATEWAY_CLIENT_NAMES.CLI &&", + "\t\thasSharedSecretAuth &&", + "\t\tisLoopback &&", + `\t\t!hasStoredOperatorDeviceAuthToken(resolveDeviceIdentityForGatewayCall()); // ${CALL_STORED_IDENTITY_MARKER} (#4462)`, +].join("\n"); + type PatchStatus = "already-applied" | "no-match" | "would-apply"; interface ReplacementResult { @@ -294,10 +326,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 +690,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 +734,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 = [ @@ -717,6 +825,48 @@ const STATE_APPROVAL_PERSIST_REPLACEMENT = [ ].join("\n"); const FILE_SPECS: FileSpec[] = [ + { + id: "gateway-call-device-identity", + label: "gateway call device-identity runtime", + marker: CALL_STORED_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)) && + (source.includes(CALL_STORED_IDENTITY_TARGET) || + source.includes(CALL_STORED_IDENTITY_MARKER)) + ); + }, + patch(source, file) { + let result: ReplacementResult = { source }; + let changed = false; + if (!result.source.includes(CALL_FORCE_IDENTITY_MARKER)) { + result = replaceExactlyOnce( + result.source, + CALL_OMIT_IDENTITY_TARGET, + CALL_OMIT_IDENTITY_REPLACEMENT, + "gateway call forced device-identity target", + file, + ); + if (result.error) return { source, status: "no-match", error: result.error }; + changed = true; + } + if (!result.source.includes(CALL_STORED_IDENTITY_MARKER)) { + result = replaceExactlyOnce( + result.source, + CALL_STORED_IDENTITY_TARGET, + CALL_STORED_IDENTITY_REPLACEMENT, + "gateway call stored device-identity target", + file, + ); + if (result.error) return { source, status: "no-match", error: result.error }; + changed = true; + } + return { source: result.source, status: changed ? "would-apply" : "already-applied" }; + }, + }, { id: "devices-cli", label: "devices CLI approval runtime", @@ -807,10 +957,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 +976,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 +1095,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 +1114,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/patch-openclaw-shared-state-permissions.mts b/scripts/patch-openclaw-shared-state-permissions.mts new file mode 100755 index 00000000000..8c66f45124a --- /dev/null +++ b/scripts/patch-openclaw-shared-state-permissions.mts @@ -0,0 +1,505 @@ +#!/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'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 + * 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 MIGRATION_MARKER = "/* nemoclaw: ignore legacy OpenClaw update-check state */"; +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));', + "}", +].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", + "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_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 = [ + MANAGED_RUNTIME_ENV_HELPER, + UPSTREAM_MIGRATION_FUNCTION_START, + `\tif (nemoclawUsesManagedRuntime()) return { changes, warnings }; ${MIGRATION_MARKER}`, + "\tif (!fileExists(params.detected.sourcePath)) return {", +].join("\n"); + +const PATCHED_MIGRATION_REQUIRED_PATTERNS = [ + MIGRATION_MARKER, + "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 (nemoclawUsesManagedRuntime()) return { changes, warnings };", +] 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", + "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 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 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 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 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 migrationFile = migrationCandidates[0]; + const modelsFile = modelsCandidates[0]; + const stateResult = patchOpenClawStateDbText(fs.readFileSync(stateFile, "utf8"), stateFile); + const agentResult = patchOpenClawAgentDbText(fs.readFileSync(agentFile, "utf8"), agentFile); + const migrationResult = patchOpenClawStateMigrationText( + fs.readFileSync(migrationFile, "utf8"), + migrationFile, + ); + 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 (migrationResult.patched) fs.writeFileSync(migrationFile, migrationResult.text); + if (modelsResult.patched) fs.writeFileSync(modelsFile, modelsResult.text); + const patched = + stateResult.patched || agentResult.patched || migrationResult.patched || modelsResult.patched; + return { + files: [stateFile, agentFile, migrationFile, 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/scripts/validate-openclaw-tool-search.mts b/scripts/validate-openclaw-tool-search.mts index a15fbbb1144..116d68a3461 100755 --- a/scripts/validate-openclaw-tool-search.mts +++ b/scripts/validate-openclaw-tool-search.mts @@ -12,11 +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$/], -]); 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; @@ -124,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 { @@ -151,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 }); @@ -167,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; @@ -178,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]; @@ -224,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); } @@ -578,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/scripts/verify-wechat-runtime-lock.mts b/scripts/verify-wechat-runtime-lock.mts index 24e90ce0656..12d8437df32 100644 --- a/scripts/verify-wechat-runtime-lock.mts +++ b/scripts/verify-wechat-runtime-lock.mts @@ -134,9 +134,7 @@ export function verifyWechatRuntimeLock( throw new Error(`${location} metadata does not match the reviewed lock`); } const packageName = location.slice("node_modules/".length); - const installedMetadata = packageMetadata( - path.join(installedRoot, location, "package.json"), - ); + const installedMetadata = packageMetadata(path.join(installedRoot, location, "package.json")); if (installedMetadata.version !== expectedRecord.version) { throw new Error( `installed ${packageName}@${installedMetadata.version} does not match locked ${expectedRecord.version}`, diff --git a/src/lib/actions/sandbox/auto-pair-warmup.test.ts b/src/lib/actions/sandbox/auto-pair-warmup.test.ts index b41efbe372a..ed17c581ff7 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.test.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.test.ts @@ -70,6 +70,16 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( expect(WARMUP_SCRIPT).toContain(`--session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)"`); }); + it("forces device pairing only for the provoke command on OpenClaw 2026.7.1", () => { + const [provoke, poll] = WARMUP_SCRIPT.split("command -v python3", 2); + expect(WARMUP_SCRIPT).toContain( + 'NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\\n openclaw agent --agent main -m "ping" \\', + ); + expect(provoke.match(/NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1/g)).toHaveLength(1); + expect(poll).not.toContain("NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING"); + expect(WARMUP_SCRIPT).not.toContain("export NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING"); + }); + it("keeps the v2 provoke run foreground and within the original budget (#4504)", () => { expect(WARMUP_SCRIPT).toContain('openclaw agent --agent main -m "ping" \\'); expect(WARMUP_SCRIPT).toContain(">/dev/null 2>&1 || true"); diff --git a/src/lib/actions/sandbox/auto-pair-warmup.ts b/src/lib/actions/sandbox/auto-pair-warmup.ts index 92518034327..517c82b47fe 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.ts @@ -64,12 +64,15 @@ export const WARMUP_POLL_LIST_TIMEOUT_S = 2; // (or the bounded deadline elapses) before returning — closing the race where // the approval pass that runs immediately after could otherwise list devices // before the gateway has registered the upgrade. The poll bounds are -// interpolated so the cap is asserted on real values, not source text. +// interpolated so the cap is asserted on real values, not source text. OpenClaw +// 2026.7.1 otherwise omits CLI device identity for loopback shared-token auth +// before a stored device credential exists; force pairing only on the provoke. export const WARMUP_SCRIPT = ` PROXY_ENV=/tmp/nemoclaw-proxy-env.sh [ -r "$PROXY_ENV" ] && . "$PROXY_ENV" command -v openclaw >/dev/null 2>&1 || exit 0 -openclaw agent --agent main -m "ping" \\ +NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\ + openclaw agent --agent main -m "ping" \\ --session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" >/dev/null 2>&1 || true command -v python3 >/dev/null 2>&1 || exit 0 OPENCLAW_BIN="$(command -v openclaw)" diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 1740004d255..e6cfdab7835 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -19,7 +19,7 @@ import { import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; -import { remediateReviewedOpenClawArchive } from "../../../../../scripts/lib/openclaw-npm-remediation.mts"; +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"; @@ -1298,7 +1298,7 @@ function packVerifiedOpenClawPluginArchive( tarballUrl: install.tarballUrl, }); const exactPackage = requireExactNpmPackageSpec(install.spec, install.npmPackageSpec); - const remediated = remediateReviewedOpenClawArchive({ + const remediated = remediateReviewedOpenClawPluginArchive({ archivePath: archive.archivePath, env: env as NodeJS.ProcessEnv, packageSpec: exactPackage.packageSpec, 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/onboard/authoritative-rebuild-target.test.ts b/src/lib/onboard/authoritative-rebuild-target.test.ts index 42b27bd24b0..c169a1d149e 100644 --- a/src/lib/onboard/authoritative-rebuild-target.test.ts +++ b/src/lib/onboard/authoritative-rebuild-target.test.ts @@ -165,7 +165,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/dockerfile-node-runtime-refresh.test.ts b/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts new file mode 100644 index 00000000000..38c761dcd58 --- /dev/null +++ b/src/lib/onboard/dockerfile-node-runtime-refresh.test.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { SandboxBaseImageResolutionMetadata } from "../sandbox-base-image"; +import { patchStagedDockerfile } from "./dockerfile-patch"; + +const NODE_REFRESH = "COPY --from=builder /usr/local/bin/node /usr/local/bin/node"; +const tmpRoots: string[] = []; + +function metadata(source: SandboxBaseImageResolutionMetadata["source"]) { + return { + schema: 1, + key: "resolution-key", + imageName: "ghcr.io/nvidia/nemoclaw/sandbox-base", + ref: + source === "local" + ? "nemoclaw-sandbox-base-local:test" + : "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abc", + digest: source === "local" ? null : "sha256:abc", + source, + imageId: "sha256:image", + os: "linux", + architecture: "amd64", + glibcVersion: "2.41", + requireOpenshellSandboxAbi: true, + minGlibcVersion: "2.39", + } satisfies SandboxBaseImageResolutionMetadata; +} + +function patchNodeRefresh(options: { + source: SandboxBaseImageResolutionMetadata["source"]; + baseImageRef: string | null; + trusted?: boolean; +}): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-node-refresh-test-")); + tmpRoots.push(dir); + const dockerfilePath = path.join(dir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest", + "FROM node:22-trixie-slim@sha256:builder AS builder", + "FROM ${BASE_IMAGE}", + NODE_REFRESH, + ].join("\n"), + ); + patchStagedDockerfile( + dockerfilePath, + "model", + "http://127.0.0.1:18789", + "build", + null, + null, + null, + options.baseImageRef, + false, + null, + [], + { + trustedManagedDockerfile: options.trusted ?? true, + baseImageResolutionMetadata: metadata(options.source), + }, + ); + return fs.readFileSync(dockerfilePath, "utf8"); +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("managed OpenClaw Node runtime refresh", () => { + it("elides the copy only for the exact trusted authoritative local base", () => { + const localRef = metadata("local").ref; + expect(patchNodeRefresh({ source: "local", baseImageRef: localRef })).not.toContain( + NODE_REFRESH, + ); + expect(patchNodeRefresh({ source: "local", baseImageRef: "local:other" })).toContain( + NODE_REFRESH, + ); + expect(patchNodeRefresh({ source: "local", baseImageRef: localRef, trusted: false })).toContain( + NODE_REFRESH, + ); + expect(patchNodeRefresh({ source: "version-tag", baseImageRef: null })).toContain(NODE_REFRESH); + }); + + it("keeps Dockerfile.base and the managed builder on the same Node image", () => { + const root = path.resolve(import.meta.dirname, "../../.."); + const dockerfile = fs.readFileSync(path.join(root, "Dockerfile"), "utf8"); + const baseDockerfile = fs.readFileSync(path.join(root, "Dockerfile.base"), "utf8"); + const builderImage = dockerfile.match(/^FROM (node:[^\s]+) AS builder$/m)?.[1]; + const baseImage = baseDockerfile.match(/^FROM (node:[^\s]+)$/m)?.[1]; + + expect(builderImage).toMatch(/^node:22-trixie-slim@sha256:[0-9a-f]{64}$/); + expect(baseImage).toBe(builderImage); + }); +}); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index f84966b00fa..cbcfc377de2 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -40,6 +40,8 @@ import { export { assertToolDisclosureDockerfileContract } from "./dockerfile-tool-disclosure-contract"; const SANDBOX_BASE_IMAGE = "ghcr.io/nvidia/nemoclaw/sandbox-base"; +const NODE_RUNTIME_REFRESH_INSTRUCTION = + "COPY --from=builder /usr/local/bin/node /usr/local/bin/node"; const PROXY_HOST_RE = /^[A-Za-z0-9._-]+$/; const POSITIVE_INT_RE = /^[1-9][0-9]*$/; @@ -185,6 +187,31 @@ export function patchStagedDockerfile( }, ); } + // A source=local resolution is built from this checkout's Dockerfile.base, + // whose Node image pin is kept identical to the managed Dockerfile builder. + // Copying that same 125 MB binary into the final image creates a redundant + // export layer. Keep the checked-in COPY as the safe default for published + // and legacy bases, and elide it only for this trusted, authoritative local + // base path. Custom Dockerfiles never receive trusted resolution metadata. + if ( + options.trustedManagedDockerfile === true && + options.baseImageResolutionMetadata?.imageName === SANDBOX_BASE_IMAGE && + options.baseImageResolutionMetadata.source === "local" && + sanitizedBaseImageRef === options.baseImageResolutionMetadata.ref + ) { + const instructionCount = dockerfile + .split(/\r?\n/) + .filter((line) => line.trim() === NODE_RUNTIME_REFRESH_INSTRUCTION).length; + if (instructionCount !== 1) { + throw new Error( + `Managed OpenClaw Dockerfile must contain exactly one ${NODE_RUNTIME_REFRESH_INSTRUCTION} instruction; found ${instructionCount}.`, + ); + } + dockerfile = dockerfile.replace( + NODE_RUNTIME_REFRESH_INSTRUCTION, + "# Node runtime refresh omitted: authoritative local base already uses the builder pin.", + ); + } dockerfile = dockerfile.replace( /^ARG NEMOCLAW_MODEL=.*$/m, `ARG NEMOCLAW_MODEL=${sanitizedModel}`, diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index 11fd77fc43c..cd9927e0523 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -36,12 +36,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", - "9f9b30bc814dd5bd36536c9061063d2b81b6041f453cda46b8dfdfb3e8693a19", + "4e548aafe9484a887a0ab0cf92ec82f77843fd346de7a2dff50b93ebd632b044", "737edaaa69f80cf10d42fd349e0be068c1ef6e7375d5dcb4055b012420b58736", "5b814e92449a6778385f588877fe72ebed80e601f8eb0c90c2842b17a489f3da", "0e1a9a7bab2fab0a974577c3af8785157b4b9be2b4db32d5f4f9e5aa3c8c8171", "a68297161e2c6463440b822f4e4be0518e745fb5fba8c61ab53b876724f7b666", - "865a9e486e1f0f54e33138a94d5cf51feb67daec4b6e6f0e21f9de22ef7e10f7", + "a54e2ac58ef00d7080ad697cb1892bf91b7bffe011f698df17b936c9906cd4af", "ca493ae7905fae5c587a8e5c31fcb3d423235940589c2decee99d7b338e87d88", "d181ff3c36d8982f78b5627d1f4a02fd30d2667cd1ca8ffb97fb65535ae452ee", "6d4094a9d7c21eeb408cadd728da7cd7e0ee9574746436be59c26b218c8ab218", @@ -50,6 +50,7 @@ const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "42ef0b12e92ebe146c25367831b4ce3a2664f0fa99fd5e4fb98a8939d3af8800", "8b49e78185185f1b7e24d01631186554fef21d2300db65c9bc9998e7ec00469f", "a0a554d474cb70087e50686d998915eae06201d6182a2410d3ccc4879e5058e6", + "5af905889f94ffed2f6c371111d0589e38eed7b0de54ddb0dd68ad912a23149a", "1197b99bdb996b37a3e4e386a507dfabcdfb2c26a40b015d617f97208668187d", ]); diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index d07068f7ed1..02a6e53cbdc 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -256,6 +256,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", "patch-bundled-npm-tar.mts"), path.join(stagedScriptsDir, "patch-bundled-npm-tar.mts"), diff --git a/test/e2e/README.md b/test/e2e/README.md index a745ff92cc1..11bd333e9fb 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -404,7 +404,7 @@ phase limits in the budget file, and limits the longest onboard output gap to `full-e2e`, and the target writes its evidence to `onboard-progress-budget.json`. When changed base-image inputs require the authoritative local OpenClaw base -build, the target applies the separately calibrated 31-second allowance only to +build, the target applies the separately calibrated 90-second allowance only to the root-start and sandbox-phase limits. The installer must emit the exact local base-build reason before the allowance applies. Published-image runs retain the normal limits, and output silence, first-turn, and all other phase requirements 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/issue-4462-admin-approval-helper.ts b/test/e2e/live/issue-4462-admin-approval-helper.ts index 7a8bca81153..81f3c3f03ff 100644 --- a/test/e2e/live/issue-4462-admin-approval-helper.ts +++ b/test/e2e/live/issue-4462-admin-approval-helper.ts @@ -155,7 +155,7 @@ export function adminApprovalConnectScript( 'echo "ISSUE_5324_STAGE=explicit-admin-approval"', 'if ! openclaw devices approve "$request_id" >"$approve_output" 2>&1; then echo "ADMIN_APPROVE_FAILED" >&2; exit 27; fi', 'if ! openclaw cron add --name "$cron_name" --every 2h --agent main --session isolated --message "hello" >"$cron_output" 2>&1; then echo "ADMIN_CRON_RETRY_FAILED" >&2; exit 28; fi', - // OpenClaw 2026.6.10 classifies cron.add and cron.run at the same + // OpenClaw 2026.6.10 and 2026.7.1 classify cron.add and cron.run at the same // operator.admin gateway-method boundary (gateway/methods/core-descriptors.ts). // The exact-request approval above therefore grants the scope both use. // The cron.run response is validated below after the final agent proof so diff --git a/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts b/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts index 40ea53798a1..34a9cc845ae 100644 --- a/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts +++ b/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts @@ -256,7 +256,10 @@ rebootstrap_write_cli_to_pairing() { while [ "$attempt" -lt 10 ]; do ( unset OPENCLAW_GATEWAY_URL OPENCLAW_GATEWAY_PORT OPENCLAW_GATEWAY_TOKEN - command openclaw devices list --json >/dev/null 2>&1 + # OpenClaw 2026.7.1 omits CLI identity for loopback shared-token auth. + # Scope the compatibility marker to this deliberate re-pair provoke. + NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \ + command openclaw devices list --json >/dev/null 2>&1 ) || true state="$(state_json)" paired_record="$(printf '%s' "$state" | select_paired_cli_device 2>/dev/null || true)" @@ -1172,7 +1175,12 @@ if [ -z "$request_id" ]; then rm -f "/sandbox/.openclaw/agents/main/sessions/\${session_id}.jsonl.lock" \ "/sandbox/.openclaw/agents/main/sessions/\${session_id}.trajectory.jsonl" 2>/dev/null || true set +e - trigger_output="$(openclaw agent --agent main --json --session-id "$session_id" -m 'What is 6 multiplied by 7? Reply with only the integer, no extra words.' 2>&1)" + # OpenClaw 2026.7.1 needs forced pairing on this shared-token scope-upgrade provoke to retain CLI identity. + trigger_output="$( + NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \ + openclaw agent --agent main --json --session-id "$session_id" \ + -m 'What is 6 multiplied by 7? Reply with only the integer, no extra words.' 2>&1 + )" trigger_rc=$? set -e printf '%s\n' "$trigger_output" >/tmp/issue4462-trigger-agent.log 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/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/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/openshell-gateway-upgrade-helpers.ts b/test/e2e/live/openshell-gateway-upgrade-helpers.ts index 5b7bcac228b..f592403449c 100644 --- a/test/e2e/live/openshell-gateway-upgrade-helpers.ts +++ b/test/e2e/live/openshell-gateway-upgrade-helpers.ts @@ -59,6 +59,17 @@ export function currentGatewayUpgradeInstallerArgs( return options.interactive ? [installer] : [installer, ...NON_INTERACTIVE_INSTALLER_ARGS]; } +export function currentNemoclawUpgradeRef(env: NodeJS.ProcessEnv): string { + for (const candidate of [ + env.NEMOCLAW_CURRENT_NEMOCLAW_REF, + env.NEMOCLAW_E2E_EXPECTED_SHA, + env.GITHUB_SHA, + ]) { + if (candidate?.trim()) return candidate.trim(); + } + return "HEAD"; +} + export function expectedLegacyRegistryMetadata(nemoclawRef: string): { nemoclawVersion: string | undefined; fromDockerfile: null | undefined; @@ -69,6 +80,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-old-installer.ts b/test/e2e/live/openshell-gateway-upgrade-old-installer.ts index 753ae43e59a..f95ce3ae86c 100644 --- a/test/e2e/live/openshell-gateway-upgrade-old-installer.ts +++ b/test/e2e/live/openshell-gateway-upgrade-old-installer.ts @@ -44,6 +44,13 @@ const REVIEWED_OLD_OPENCLAW_ARCHIVES: Readonly = + 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 { fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); @@ -196,6 +219,308 @@ 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 }); +globalDb.exec("PRAGMA busy_timeout = 5000"); +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(); +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 }); + 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); + 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; +} + +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 + // 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, + ); + // 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( + 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"); + expectStatePreservedAcrossUpgrade(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(); + function createOldDockerWrapper(artifacts: ArtifactSink): string { const wrapperDir = artifacts.pathFor("old-docker-wrapper"); const logFile = artifacts.pathFor("old-docker-wrapper.log"); @@ -591,7 +916,7 @@ async function installCurrentNemoclawUpgrade( currentInstallLog: string, hiddenOldOpenShellDir?: string, ): Promise { - const currentRef = process.env.NEMOCLAW_CURRENT_NEMOCLAW_REF ?? process.env.GITHUB_SHA ?? "HEAD"; + const currentRef = currentNemoclawUpgradeRef(process.env); const currentRefResult = await bash( host, currentRef === "HEAD" ? "git rev-parse HEAD" : `printf '%s' ${shellQuote(currentRef)}`, @@ -624,8 +949,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", @@ -634,10 +962,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, @@ -803,6 +1135,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, }); @@ -836,6 +1174,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 () => { @@ -847,6 +1187,7 @@ runLinuxOpenShellGatewayUpgrade( }); await installOldNemoclawAndClaw(host, artifacts, fake.baseUrl); + const legacyStateContract = await captureOpenClawStateUpgradeProof(host, fake, artifacts); const hiddenOldOpenShellDir = OLD_NEMOCLAW_REF === "v0.0.55" ? await stageOldOpenShellInUserLocalBin(host) : undefined; const survivorPid = await startSurvivorAgentInExistingClaw(host); @@ -858,6 +1199,7 @@ runLinuxOpenShellGatewayUpgrade( hiddenOldOpenShellDir, ); await assertSurvivorSandboxAfterUpgrade(host); + await verifyOpenClawStateUpgradeProof(host, fake, artifacts, legacyStateContract); }, ); 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/e2e-live-target-gating.test.ts b/test/e2e/support/e2e-live-target-gating.test.ts index 2f7e560da33..32b4428cfa7 100644 --- a/test/e2e/support/e2e-live-target-gating.test.ts +++ b/test/e2e/support/e2e-live-target-gating.test.ts @@ -67,38 +67,42 @@ function linesForFile(lines: readonly string[], file: string): string[] { } describe("live E2E target gating", () => { - it("collects the bootstrap install test through the trusted-main legacy path", () => { - const legacy = listLiveTests({ - enabled: true, - env: { E2E_TARGET_ID: "launchable-smoke" }, - files: ["launchable-smoke.test.ts"], - }); + it( + "collects the bootstrap install test through the trusted-main legacy path", + testTimeoutOptions(90_000), + () => { + const legacy = listLiveTests({ + enabled: true, + env: { E2E_TARGET_ID: "launchable-smoke" }, + files: ["launchable-smoke.test.ts"], + }); - expect(legacy.status, legacy.stderr || legacy.stdout).toBe(0); - expect(linesForFile(legacy.lines, "launchable-smoke.test.ts")).toEqual([ - "[e2e-live] test/e2e/live/launchable-smoke.test.ts > bootstrap install smoke: bootstrap, onboard, sandbox health, live inference, cleanup", - ]); + expect(legacy.status, legacy.stderr || legacy.stdout).toBe(0); + expect(linesForFile(legacy.lines, "launchable-smoke.test.ts")).toEqual([ + "[e2e-live] test/e2e/live/launchable-smoke.test.ts > bootstrap install smoke: bootstrap, onboard, sandbox health, live inference, cleanup", + ]); - const renamed = listLiveTests({ - enabled: true, - env: { E2E_TARGET_ID: "bootstrap-install-smoke" }, - files: ["bootstrap-install-smoke.test.ts"], - }); + const renamed = listLiveTests({ + enabled: true, + env: { E2E_TARGET_ID: "bootstrap-install-smoke" }, + files: ["bootstrap-install-smoke.test.ts"], + }); - expect(renamed.status, renamed.stderr || renamed.stdout).toBe(0); - expect(linesForFile(renamed.lines, "bootstrap-install-smoke.test.ts")).toEqual([ - "[e2e-live] test/e2e/live/bootstrap-install-smoke.test.ts > bootstrap install smoke: bootstrap, onboard, sandbox health, live inference, cleanup", - ]); + expect(renamed.status, renamed.stderr || renamed.stdout).toBe(0); + expect(linesForFile(renamed.lines, "bootstrap-install-smoke.test.ts")).toEqual([ + "[e2e-live] test/e2e/live/bootstrap-install-smoke.test.ts > bootstrap install smoke: bootstrap, onboard, sandbox health, live inference, cleanup", + ]); - const inactive = listLiveTests({ - enabled: true, - env: { E2E_TARGET_ID: "launchable-smoke" }, - files: ["bootstrap-install-smoke.test.ts"], - }); + const inactive = listLiveTests({ + enabled: true, + env: { E2E_TARGET_ID: "launchable-smoke" }, + files: ["bootstrap-install-smoke.test.ts"], + }); - expect(inactive.status, inactive.stderr || inactive.stdout).toBe(0); - expect(linesForFile(inactive.lines, "bootstrap-install-smoke.test.ts")).toEqual([]); - }); + expect(inactive.status, inactive.stderr || inactive.stdout).toBe(0); + expect(linesForFile(inactive.lines, "bootstrap-install-smoke.test.ts")).toEqual([]); + }, + ); it("collects no live files without project opt-in and all live files with it", () => { const disabled = listLiveTests({ enabled: false, filesOnly: true }); 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/e2e/support/openshell-gateway-upgrade-old-installer.test.ts b/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts index 16d19f59b88..4ad58142b00 100644 --- a/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts +++ b/test/e2e/support/openshell-gateway-upgrade-old-installer.test.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { REPO_ROOT } from "../fixtures/paths.ts"; import { OLD_INSTALLER_ADVISORY_AUDIT, OLD_INSTALLER_ARCHIVE_CONTEXT_PATH, @@ -14,23 +15,25 @@ import { patchOldInstallerFixture, reviewedOldOpenClawArchive, } from "../live/openshell-gateway-upgrade-old-installer.ts"; -import { REPO_ROOT } from "../fixtures/paths.ts"; const temporaryDirectories: string[] = []; const HISTORICAL_BUILD_CONTEXT_MODULES = Object.freeze({ "v0.0.36": "src/lib/sandbox-build-context.ts", "v0.0.55": "src/lib/sandbox/build-context.ts", "v0.0.74": "src/lib/sandbox/build-context.ts", + "v0.0.89": "src/lib/sandbox/build-context.ts", }); const HISTORICAL_OPENCLAW_VERSIONS = Object.freeze({ "v0.0.36": "2026.4.24", "v0.0.55": "2026.5.22", "v0.0.74": "2026.5.27", + "v0.0.89": "2026.6.10", }); const HISTORICAL_NEMOCLAW_COMMITS = Object.freeze({ "v0.0.36": "3351fbdd4eb7d9b80ec471545083956327da2b10", "v0.0.55": "95d483fe2b6569d68e59493c60f19df09a068e8f", "v0.0.74": "3a05b54e8ec3e1d5550ec5c728de54af872bffe3", + "v0.0.89": "1143aa5cce77f3bad1b3b5588bd7fddbe438237e", }); type ReviewedHistoricalRef = keyof typeof HISTORICAL_BUILD_CONTEXT_MODULES; @@ -221,16 +224,19 @@ describe("historical OpenShell gateway upgrade installer adapter", () => { expect(dockerfile).not.toContain( "Skipping current advisory audit for the immutable historical mcporter lock", ); - }); + }, 30_000); - it("accepts the reviewed v0.0.74 advisory and signature audit boundary", () => { - const dockerfile = runReviewedHistoricalFixture("v0.0.74"); + it.each([ + "v0.0.74", + "v0.0.89", + ] as const)("accepts the reviewed %s advisory and signature audit boundary", (nemoclawRef) => { + const dockerfile = runReviewedHistoricalFixture(nemoclawRef); expect(dockerfile).not.toContain("audit --omit=dev --audit-level=low"); expect(dockerfile).toContain( "Skipping current advisory audit for the immutable historical mcporter lock", ); expect(dockerfile).toContain("audit signatures"); - }); + }, 30_000); it("rejects an ambiguous historical advisory boundary", () => { const fixture = writeHistoricalFixture(2); @@ -326,6 +332,10 @@ describe("historical OpenShell gateway upgrade installer adapter", () => { "2026.5.27", "sha512-2N93zhdAo88KAbHt6T7KvYXf4s7XIkYXBgv1npYpn7e1Y9FvrtgtpsA38my9rtFW+70uXEojRPX5/OqnuDqJPw==", ], + [ + "2026.6.10", + "sha512-LcooND2tBQw8A+kc1Ujltu3lg30bJ0w7XaeRy7eYzobb8BBdcW6DOGbwJL4vpj1vl9+gjRceOtlh5nh9OARcug==", + ], ])("binds historical OpenClaw %s to its reviewed archive", (version, expectedIntegrity) => { expect(reviewedOldOpenClawArchive(version)).toEqual({ expectedIntegrity, 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 364a759e755..e0fb0bc2d0b 100644 --- a/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts +++ b/test/e2e/support/openshell-gateway-upgrade-workflow-boundary.test.ts @@ -16,6 +16,7 @@ import { } from "../../../tools/e2e/workflow-boundary.mts"; import { currentGatewayUpgradeInstallerArgs, + currentNemoclawUpgradeRef, expectedLegacyRegistryMetadata, oldGatewayUpgradeInstallerArgs, upgradeGatewayCleanupScript, @@ -38,7 +39,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 +54,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", ]), ); @@ -72,6 +80,27 @@ describe("OpenShell gateway upgrade workflow boundary", () => { ); }); + it("installs the selected E2E checkout instead of the trusted workflow SHA", () => { + expect( + currentNemoclawUpgradeRef({ + NEMOCLAW_E2E_EXPECTED_SHA: "candidate-sha", + GITHUB_SHA: "trusted-main-sha", + }), + ).toBe("candidate-sha"); + expect( + currentNemoclawUpgradeRef({ + NEMOCLAW_CURRENT_NEMOCLAW_REF: "explicit-ref", + NEMOCLAW_E2E_EXPECTED_SHA: "candidate-sha", + GITHUB_SHA: "trusted-main-sha", + }), + ).toBe("explicit-ref"); + expect(currentNemoclawUpgradeRef({ GITHUB_SHA: "workflow-sha" })).toBe("workflow-sha"); + expect( + currentNemoclawUpgradeRef({ NEMOCLAW_E2E_EXPECTED_SHA: "", GITHUB_SHA: "workflow-sha" }), + ).toBe("workflow-sha"); + expect(currentNemoclawUpgradeRef({})).toBe("HEAD"); + }); + it("pins the registry metadata written by each historical release fixture", () => { const absentMetadata = { nemoclawVersion: undefined, fromDockerfile: undefined }; expect(expectedLegacyRegistryMetadata("v0.0.36")).toEqual(absentMetadata); @@ -80,7 +109,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/, ); }); @@ -98,6 +131,18 @@ describe("OpenShell gateway upgrade workflow boundary", () => { expect(validateLegacyGatewayUpgradeFixture(fixture)).toEqual({ sandboxBaseDigest: "10433a8cd2f2b809dd0fdf983514679e04c0f8aa1ff5bbff675029046033b108", }); + expect( + validateLegacyGatewayUpgradeFixture({ + nemoclawRef: "v0.0.89", + nemoclawCommit: "1143aa5cce77f3bad1b3b5588bd7fddbe438237e", + installerSha256: "00f24959e5ca68104fe91221c0a015dab6a4154618497fa36b969b661f418cc2", + openclawVersion: "2026.6.10", + sandboxBaseImageRef: + "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:3265d482f67c9d81ee3a59b0bbad5eb5ea6c705fea81ece8ae888ed12794f7f1", + }), + ).toEqual({ + sandboxBaseDigest: "3265d482f67c9d81ee3a59b0bbad5eb5ea6c705fea81ece8ae888ed12794f7f1", + }); expect(() => validateLegacyGatewayUpgradeFixture({ ...fixture, 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 1538b60ac1c..2d8d485055f 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", ); } @@ -141,8 +141,6 @@ function runOpenClawUpgradeBlock(currentVersion: string) { const openclawVersion = readDockerfileOpenClawVersion(); const reviewedArchiveDir = path.join(tmp, "reviewed-pack"); const reviewedArchive = path.join(reviewedArchiveDir, `openclaw-${openclawVersion}.tgz`); - const remediatedArchive = path.join(tmp, "openclaw-remediated.tgz"); - const remediationHelper = path.join(tmp, "openclaw-npm-remediation.cjs"); const expectedMcporterVersion = readDockerfileMcporterVersion(); const openclawIntegrity = readDockerfileOpenClawIntegrity(); const openclawTarball = readDockerfileOpenClawTarball(); @@ -160,17 +158,6 @@ function runOpenClawUpgradeBlock(currentVersion: string) { fs.writeFileSync(openclawShim, ""); fs.writeFileSync(mcporterShim, ""); fs.writeFileSync(reviewedArchive, "fake reviewed OpenClaw archive"); - fs.writeFileSync( - remediationHelper, - [ - 'const fs = require("node:fs");', - "const args = process.argv.slice(2);", - "const value = (name) => args[args.indexOf(name) + 1];", - `fs.copyFileSync(value("--archive"), ${JSON.stringify(remediatedArchive)});`, - `console.log(${JSON.stringify(remediatedArchive)});`, - "", - ].join("\n"), - ); const command = dockerRunCommandBetween( "# OPENCLAW_VERSION is the NemoClaw runtime build target", "# Patch OpenClaw media fetch", @@ -180,8 +167,7 @@ function runOpenClawUpgradeBlock(currentVersion: string) { .replaceAll("/usr/local/bin/openclaw", openclawShim) .replaceAll("/usr/local/lib/node_modules/mcporter", mcporterInstall) .replaceAll("/usr/local/lib/nemoclaw/mcporter-runtime", mcporterInstall) - .replaceAll("/usr/local/bin/mcporter", mcporterShim) - .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationHelper); + .replaceAll("/usr/local/bin/mcporter", mcporterShim); const script = [ "#!/usr/bin/env bash", "set -euo pipefail", @@ -192,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() {", @@ -208,8 +194,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;', @@ -221,7 +207,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', @@ -229,7 +215,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', @@ -241,10 +227,9 @@ 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:-}" = "ls" ]; then return 0; fi', ' if [ "${1:-}" = "install" ]; then return 0; fi', ' if [ "${1:-}" = "--prefix" ]; then return 0; fi', " return 1", @@ -294,11 +279,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)", ); }); @@ -356,7 +341,6 @@ describe("fetch-guard patch regression guard", () => { "npm install -g --no-audit --no-fund --no-progress --ignore-scripts ", ); expect(stale.calls).toContain("postinstall-bundled-plugins.mjs"); - expect(stale.calls).toContain("npm ls -g --depth=1 openclaw @openclaw/fs-safe tar jszip"); expect(stale.calls).toContain( `openclaw-${CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION}.tgz`, ); @@ -373,12 +357,11 @@ describe("fetch-guard patch regression guard", () => { "npm install -g --no-audit --no-fund --no-progress --ignore-scripts ", ); expect(current.calls).toContain("postinstall-bundled-plugins.mjs"); - expect(current.calls).toContain("npm ls -g --depth=1 openclaw @openclaw/fs-safe tar jszip"); expect(current.calls).toContain( `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, @@ -1136,7 +1119,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 };", @@ -1341,7 +1326,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( @@ -1394,7 +1379,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..9b75450522f 100644 --- a/test/helpers/openclaw-device-self-approval-patch-harness.ts +++ b/test/helpers/openclaw-device-self-approval-patch-harness.ts @@ -15,6 +15,32 @@ 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" }; +let storedOperatorDeviceAuthToken = false; +function isLoopbackGatewayUrl(url) { return url.startsWith("ws://127.0.0.1:"); } +function resolveDeviceIdentityForGatewayCall() { return { deviceId: "device-1" }; } +function hasStoredOperatorDeviceAuthToken() { return storedOperatorDeviceAuthToken; } +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 setStoredOperatorDeviceAuthToken(value) { storedOperatorDeviceAuthToken = value; } +`); +} + function cliFixture(): string { return compiledIndent(` const ADMIN_SCOPE = "operator.admin"; @@ -176,6 +202,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 +270,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; @@ -433,7 +517,9 @@ 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()); 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..4fd2a5f1ba7 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; @@ -147,6 +148,46 @@ function requireRealStoredDeviceAuthLinkage(sources: DistSource[], cliSource: Di "opts.requiredStoredDeviceAuthScopes", "scopes: useStoredDeviceAuth ? void 0 : scopes", ]); + const gatewayHandshake = requireExactlyOneDistSource( + sources, + "shared-auth paired-device scope enforcement", + [ + "async function resolveConnectAuthDecisionCore(params)", + "if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) return finish();", + "if (device && devicePublicKey) {", + 'if (!await requirePairing("scope-upgrade", paired)) return;', + ], + ); + requireOrderedMarkers( + gatewayCall.source, + [ + "function shouldOmitDeviceIdentityForGatewayCall(params)", + "NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING", + "nemoclaw: force device identity for loopback pairing bootstrap", + "const mode = params.opts.mode", + "const isLocalCliSharedAuth =", + "!hasStoredOperatorDeviceAuthToken(resolveDeviceIdentityForGatewayCall())", + "nemoclaw: retain stored CLI device identity for loopback shared-token scope enforcement", + "return isLocalBackendSharedAuth || isLocalCliSharedAuth;", + ], + "loopback CLI shared-token stored device identity", + ); + requireOrderedMarkers( + gatewayHandshake.source, + [ + "async function resolveConnectAuthDecisionCore(params)", + "let authOk = params.state.authOk;", + "if (!params.hasDeviceIdentity || !params.deviceId || authOk || !deviceTokenCandidate) return finish();", + "if (device && devicePublicKey) {", + "const paired = await getPairedDevice(device.id);", + "const pairedScopes = resolvePairedAccessScopes(paired);", + "if (scopes.length > 0) {", + "requestedScopes: scopes,", + "allowedScopes: pairedScopes", + 'if (!await requirePairing("scope-upgrade", paired)) return;', + ], + "shared-token identity to paired-scope upgrade linkage", + ); requireOrderedMarkers( gatewayCall.source, [ @@ -520,7 +561,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 +661,7 @@ throw new Error("injected crash did not terminate the process"); ); const restart = spawnSync( - process.execPath, + options.nodeExecutable, [ "--input-type=module", "-e", @@ -672,7 +713,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 +765,7 @@ function runRejectedRenameRollbackProof( ): void { const fixture = createPairingTransactionFixture(options.tmp, "rejected-rename", journalBasename); const proof = spawnSync( - process.execPath, + options.nodeExecutable, [ "--input-type=module", "-e", @@ -893,12 +934,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 +948,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, @@ -937,28 +980,29 @@ async function runLiveConfigTokenSelfApprovalProof(options: ProofOptions): Promi OPENCLAW_STATE_DIR: stateDir, }; const runCli = (args: string[]) => - spawnSync(process.execPath, [openclawEntry, ...args], { + spawnSync(options.nodeExecutable, [openclawEntry, ...args], { cwd: packageDir, encoding: "utf8", env, 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(options.nodeExecutable, [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 +1042,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 +1072,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 +1101,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 +1152,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); @@ -1116,7 +1168,7 @@ async function runLiveConfigTokenSelfApprovalProof(options: ProofOptions): Promi export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptions): Promise { const patch = spawnSync( - process.execPath, + options.nodeExecutable, ["--experimental-strip-types", options.patchScript, options.dist], { encoding: "utf8", @@ -1131,7 +1183,7 @@ export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptio ); const audit = spawnSync( - process.execPath, + options.nodeExecutable, ["--experimental-strip-types", options.patchScript, "--audit", options.dist], { encoding: "utf8", @@ -1140,17 +1192,21 @@ 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: 3 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", "nemoclaw: validate bounded self-approval inside pairing lock", 'CLI: "cli"', @@ -1194,7 +1250,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"); @@ -1294,7 +1350,7 @@ export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptio } const deviceBootstrapUrl = pathToFileURL(deviceBootstrapFile).href; const runtimeProof = spawnSync( - process.execPath, + options.nodeExecutable, [ "--input-type=module", "-e", @@ -1462,5 +1518,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/install-preflight.test.ts b/test/install-preflight.test.ts index 78ce632a334..53cc29cb36e 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -126,7 +126,7 @@ run_onboard < "$PROMPT_INPUT_FILE" // --------------------------------------------------------------------------- -describe("installer runtime preflight", { timeout: 15_000 }, () => { +describe("installer runtime preflight", { timeout: 90_000 }, () => { it("attempts nvm upgrade when system Node.js is below minimum version", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-preflight-")); const fakeBin = path.join(tmp, "bin"); 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 b98f414b1f4..f60085e5a36 100644 --- a/test/messaging-build-applier-integrity.test.ts +++ b/test/messaging-build-applier-integrity.test.ts @@ -21,7 +21,7 @@ vi.mock("../scripts/lib/openclaw-npm-remediation.mts", async (importOriginal) => await importOriginal(); return { ...original, - remediateReviewedOpenClawArchive: ({ archivePath }: { archivePath: string }) => ({ + remediateReviewedOpenClawPluginArchive: ({ archivePath }: { archivePath: string }) => ({ archivePath, integrity: "sha512-messaging-integrity-test-remediation", remediated: false, @@ -43,10 +43,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 { @@ -60,13 +60,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"); @@ -153,9 +153,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", @@ -164,11 +164,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 }); } @@ -177,19 +177,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( @@ -214,11 +214,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", @@ -227,15 +226,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 { @@ -246,7 +245,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"); @@ -267,9 +266,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", @@ -278,13 +277,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 }); @@ -315,10 +314,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", @@ -343,11 +342,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 26b855afdcd..52e33cfec35 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -28,7 +28,10 @@ const { remediateReviewedArchive } = vi.hoisted(() => ({ vi.mock("../scripts/lib/openclaw-npm-remediation.mts", async (importOriginal) => { const original = await importOriginal(); - return { ...original, remediateReviewedOpenClawArchive: remediateReviewedArchive }; + return { + ...original, + remediateReviewedOpenClawPluginArchive: remediateReviewedArchive, + }; }); beforeEach(() => { @@ -51,14 +54,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"; @@ -67,22 +70,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";', @@ -573,7 +576,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"), @@ -598,7 +601,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, }, @@ -610,20 +613,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 }); } @@ -641,18 +644,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);", @@ -676,8 +679,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(), }, @@ -687,13 +690,16 @@ 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|"); + expect(remediateReviewedArchive).toHaveBeenCalledWith( + expect.objectContaining({ packageSpec: "@openclaw/msteams@2026.7.1" }), + ); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -740,13 +746,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 { @@ -834,11 +840,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", "", @@ -861,13 +867,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", @@ -887,9 +893,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", @@ -897,19 +903,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`); @@ -928,7 +934,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( @@ -959,8 +965,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", @@ -969,19 +975,22 @@ 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|"); + expect(remediateReviewedArchive).toHaveBeenCalledWith( + expect.objectContaining({ packageSpec: "@openclaw/slack@2026.7.1" }), + ); } 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( @@ -1011,7 +1020,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", @@ -1019,11 +1028,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 }); @@ -1120,7 +1129,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);", "}", @@ -1143,7 +1152,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"), @@ -1174,8 +1183,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", }; @@ -1186,12 +1195,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/nemoclaw-start-shared-state-topology.test.ts b/test/nemoclaw-start-shared-state-topology.test.ts new file mode 100644 index 00000000000..6b41369b166 --- /dev/null +++ b/test/nemoclaw-start-shared-state-topology.test.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type SpawnSyncReturns, spawnSync } 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); + 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); +} + +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/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index daa2fb9a6c6..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:ws://127.0.0.1:18789:18789:test-gateway-token"); + 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/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index e882cdb1eb5..87bd7bcc037 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -47,6 +47,21 @@ describe("node-tar image remediation contract", () => { } }); + it.each([ + "Dockerfile.base", + "agents/hermes/Dockerfile.base", + "agents/langchain-deepagents-code/Dockerfile.base", + ])("installs curl before patching the bundled npm tar in $file", (file) => { + const source = completedStage(fs.readFileSync(path.join(repoRoot, file), "utf8")); + const curlInstall = source.indexOf("curl="); + const patchRun = source.indexOf( + "RUN node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts", + ); + + expect(curlInstall, file).toBeGreaterThanOrEqual(0); + expect(patchRun, file).toBeGreaterThan(curlInstall); + }); + it.each( dockerfiles, )("patches npm before use and scans the completed $file filesystem", (entry) => { diff --git a/test/openclaw-2026-6-npm-remediation.test.ts b/test/openclaw-2026-6-npm-remediation.test.ts new file mode 100644 index 00000000000..b6ee0eccc60 --- /dev/null +++ b/test/openclaw-2026-6-npm-remediation.test.ts @@ -0,0 +1,529 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { + chmodSync, + cpSync, + mkdirSync, + 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 { + buildRemediatedOpenClawArchive, + patchOpenClawCorePackageGraph, + 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.6.10", + 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.6.10", + lockfileVersion: 3, + requires: true, + packages: { + "": { + name: "@openclaw/slack", + version: "2026.6.10", + 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 writeCoreFixture(tarVersion = "7.5.16"): string { + const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-core-remediation-")); + temporaryDirectories.push(directory); + writeFileSync( + path.join(directory, "package.json"), + `${JSON.stringify( + { + name: "openclaw", + version: "2026.6.10", + dependencies: { + "@openclaw/fs-safe": "0.3.0", + jszip: "3.10.1", + minimatch: "10.2.5", + tar: tarVersion, + }, + }, + null, + 2, + )}\n`, + ); + writeFileSync( + path.join(directory, "npm-shrinkwrap.json"), + `${JSON.stringify( + { + name: "openclaw", + version: "2026.6.10", + lockfileVersion: 3, + packages: { + "": { + name: "openclaw", + version: "2026.6.10", + dependencies: { + "@openclaw/fs-safe": "0.3.0", + jszip: "3.10.1", + minimatch: "10.2.5", + tar: tarVersion, + }, + }, + "node_modules/@openclaw/fs-safe": { + version: "0.3.0", + optionalDependencies: { jszip: "^3.10.1", tar: "7.5.13" }, + }, + "node_modules/brace-expansion": { + version: "5.0.6", + resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + integrity: "sha512-old-brace-expansion", + }, + "node_modules/minimatch": { + version: "10.2.5", + dependencies: { "brace-expansion": "^5.0.5" }, + }, + "node_modules/jszip": { + version: "3.10.1", + resolved: "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + integrity: + "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + }, + "node_modules/tar": { + version: tarVersion, + resolved: `https://registry.npmjs.org/tar/-/tar-${tarVersion}.tgz`, + integrity: "sha512-old-tar", + }, + }, + }, + null, + 2, + )}\n`, + ); + return directory; +} + +function readJson(file: string): T { + return JSON.parse(readFileSync(file, "utf-8")) as T; +} + +function packFixture(packageDirectory: string, archivePath: string): void { + const root = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-archive-fixture-")); + temporaryDirectories.push(root); + cpSync(packageDirectory, path.join(root, "package"), { recursive: true }); + const result = spawnSync("tar", ["-czf", archivePath, "-C", root, "package"], { + encoding: "utf-8", + }); + expect(result.status, result.stderr || "failed to pack OpenClaw test archive").toBe(0); +} + +function writeCoreArchiveFixtures(): { + archivePath: string; + npmExecutable: string; + workingDirectory: string; +} { + const root = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-build-remediation-")); + temporaryDirectories.push(root); + const archivePath = path.join(root, "openclaw-2026.6.10.tgz"); + packFixture(writeCoreFixture(), archivePath); + + const fsSafeDirectory = path.join(root, "fs-safe-package"); + mkdirSync(fsSafeDirectory, { recursive: true }); + writeFileSync( + path.join(fsSafeDirectory, "package.json"), + `${JSON.stringify( + { + name: "@openclaw/fs-safe", + version: "0.3.0", + optionalDependencies: { jszip: "^3.10.1", tar: "7.5.13" }, + }, + null, + 2, + )}\n`, + ); + const fsSafeArchive = path.join(root, "fs-safe-0.3.0-source.tgz"); + packFixture(fsSafeDirectory, fsSafeArchive); + + const npmExecutable = path.join(root, "npm-fixture.sh"); + writeFileSync( + npmExecutable, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `fs_safe_archive=${JSON.stringify(fsSafeArchive)}`, + 'if [ "$1" = "view" ] && [ "$3" = "dist.integrity" ]; then', + ' printf "%s\\n" "sha512-uIBE441CIt1kIURoP9qRGKZ8LkGyfD9ZzeESjwAd29ZPWtghws/5GR3Pjb67jKdcJHP1I6roNXcvnhzAU7lHlA=="', + " exit 0", + "fi", + 'if [ "$1" = "view" ] && [ "$3" = "dist.tarball" ]; then', + ' printf "%s\\n" "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.3.0.tgz"', + " exit 0", + "fi", + 'if [ "$1" = "pack" ]; then', + ' destination=""', + ' while [ "$#" -gt 0 ]; do', + ' if [ "$1" = "--pack-destination" ]; then destination="$2"; shift 2; continue; fi', + " shift", + " done", + ' cp "$fs_safe_archive" "$destination/fs-safe-0.3.0.tgz"', + ' printf \'[{"filename":"fs-safe-0.3.0.tgz","integrity":"sha512-uIBE441CIt1kIURoP9qRGKZ8LkGyfD9ZzeESjwAd29ZPWtghws/5GR3Pjb67jKdcJHP1I6roNXcvnhzAU7lHlA=="}]\\n\'', + " exit 0", + "fi", + "exit 1", + "", + ].join("\n"), + { mode: 0o700 }, + ); + chmodSync(npmExecutable, 0o700); + return { archivePath, npmExecutable, workingDirectory: path.join(root, "work") }; +} + +function writePluginArchiveFixtures(): { + archivePath: string; + npmExecutable: string; + workingDirectory: string; +} { + const root = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-plugin-remediation-")); + temporaryDirectories.push(root); + const archivePath = path.join(root, "slack-2026.6.10.tgz"); + packFixture(writeFixture(), archivePath); + + const replacements = [ + { + archive: "axios-1.18.0-source.tgz", + dependencies: { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0", + }, + name: "axios", + version: "1.18.0", + }, + { + archive: "https-proxy-agent-5.0.1-source.tgz", + dependencies: { "agent-base": "6", debug: "4" }, + name: "https-proxy-agent", + version: "5.0.1", + }, + { + archive: "agent-base-6.0.2-source.tgz", + dependencies: { debug: "4" }, + name: "agent-base", + version: "6.0.2", + }, + ] as const; + for (const replacement of replacements) { + const directory = path.join(root, `${replacement.name}-package`); + mkdirSync(directory, { recursive: true }); + writeFileSync( + path.join(directory, "package.json"), + `${JSON.stringify( + { + dependencies: replacement.dependencies, + name: replacement.name, + version: replacement.version, + }, + null, + 2, + )}\n`, + ); + packFixture(directory, path.join(root, replacement.archive)); + } + + const npmExecutable = path.join(root, "npm-fixture.sh"); + writeFileSync( + npmExecutable, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `fixture_root=${JSON.stringify(root)}`, + 'case "$1:$2:${3:-}" in', + ' "view:axios@1.18.0:dist.integrity") value="sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==" ;;', + ' "view:axios@1.18.0:dist.tarball") value="https://registry.npmjs.org/axios/-/axios-1.18.0.tgz" ;;', + ' "view:https-proxy-agent@5.0.1:dist.integrity") value="sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==" ;;', + ' "view:https-proxy-agent@5.0.1:dist.tarball") value="https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz" ;;', + ' "view:agent-base@6.0.2:dist.integrity") value="sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==" ;;', + ' "view:agent-base@6.0.2:dist.tarball") value="https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" ;;', + ' "pack:https://registry.npmjs.org/axios/-/axios-1.18.0.tgz:--pack-destination") archive="axios-1.18.0-source.tgz"; filename="axios-1.18.0.tgz"; integrity="sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==" ;;', + ' "pack:https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz:--pack-destination") archive="https-proxy-agent-5.0.1-source.tgz"; filename="https-proxy-agent-5.0.1.tgz"; integrity="sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==" ;;', + ' "pack:https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz:--pack-destination") archive="agent-base-6.0.2-source.tgz"; filename="agent-base-6.0.2.tgz"; integrity="sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==" ;;', + ' *) echo "unexpected npm fixture invocation: $*" >&2; exit 1 ;;', + "esac", + 'if [ "$1" = "view" ]; then printf "%s\\n" "$value"; exit 0; fi', + 'destination=""', + 'while [ "$#" -gt 0 ]; do', + ' if [ "$1" = "--pack-destination" ]; then destination="$2"; shift 2; continue; fi', + " shift", + "done", + 'cp "$fixture_root/$archive" "$destination/$filename"', + 'printf \'[{"filename":"%s","integrity":"%s"}]\\n\' "$filename" "$integrity"', + "", + ].join("\n"), + { mode: 0o700 }, + ); + chmodSync(npmExecutable, 0o700); + return { archivePath, npmExecutable, workingDirectory: path.join(root, "work") }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("OpenClaw npm remediation", () => { + // source-shape-contract: security -- Exact replacement metadata binds the rebuilt plugin archive to the reviewed registry identities + it("replaces the reviewed bundled Axios graph with the patched graph", () => { + const directory = writeFixture(); + + patchOpenClawPluginPackageGraph(directory, "@openclaw/slack@2026.6.10"); + + const shrinkwrap = readJson<{ + packages: Record }>; + }>(path.join(directory, "npm-shrinkwrap.json")); + const packageJson = readJson<{ + bundledDependencies?: string[]; + dependencies?: Record; + }>(path.join(directory, "package.json")); + expect(packageJson.dependencies).toMatchObject({ axios: "1.18.0" }); + expect(packageJson.bundledDependencies).toContain("axios"); + 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", + 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", () => { + const directory = writeFixture("1.17.0"); + + expect(() => patchOpenClawPluginPackageGraph(directory, "@openclaw/slack@2026.6.10")).toThrow( + "must resolve node_modules/axios to 1.16.0 before remediation", + ); + }); + + // source-shape-contract: security -- Exact core shrinkwrap metadata binds remediation output to the reviewed registry identities + it("replaces the reviewed OpenClaw core tar and brace-expansion graph", () => { + const directory = writeCoreFixture(); + + patchOpenClawCorePackageGraph(directory); + + const shrinkwrap = readJson<{ + packages: Record< + string, + { + dependencies?: Record; + integrity?: string; + optionalDependencies?: Record; + resolved?: string; + version?: string; + } + >; + }>(path.join(directory, "npm-shrinkwrap.json")); + const packageJson = readJson<{ + bundledDependencies?: string[]; + dependencies?: Record; + }>(path.join(directory, "package.json")); + expect(packageJson.dependencies).toMatchObject({ jszip: "3.10.1", tar: "7.5.19" }); + expect(packageJson.bundledDependencies).toEqual(["@openclaw/fs-safe"]); + expect(shrinkwrap.packages[""]).toMatchObject({ dependencies: { tar: "7.5.19" } }); + expect(shrinkwrap.packages["node_modules/tar"]).toMatchObject({ + version: "7.5.19", + resolved: "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + integrity: + "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + }); + expect(shrinkwrap.packages["node_modules/@openclaw/fs-safe"]?.optionalDependencies).toBe( + undefined, + ); + expect(shrinkwrap.packages["node_modules/brace-expansion"]).toMatchObject({ + version: "5.0.7", + resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + integrity: + "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + }); + }); + + it("rejects an OpenClaw core tar graph that changed after review", () => { + const directory = writeCoreFixture("7.5.17"); + + expect(() => patchOpenClawCorePackageGraph(directory)).toThrow( + "must declare reviewed tar@7.5.16 before remediation", + ); + }); + + // source-shape-contract: security -- Archive metadata proves the rebuilt package carries the reviewed bundled fs-safe remediation + it("rebuilds a guarded core archive with the patched fs-safe package bundled", () => { + const fixture = writeCoreArchiveFixtures(); + const request = { + archivePath: fixture.archivePath, + env: { NEMOCLAW_REVIEWED_NPM_EXECUTABLE: fixture.npmExecutable }, + packageSpec: "openclaw@2026.6.10", + workingDirectory: fixture.workingDirectory, + }; + let metadataIntegrity = ""; + try { + buildRemediatedOpenClawArchive({ + ...request, + expectedPatchedMetadataIntegrity: "sha512-deliberate-mismatch", + }); + } catch (error) { + const message = String(error); + expect(message).toMatch(/got sha512-\S+/u); + metadataIntegrity = message.match(/got (sha512-\S+)/u)?.[1] ?? ""; + } + expect(metadataIntegrity).toMatch(/^sha512-/u); + + const remediated = buildRemediatedOpenClawArchive({ + ...request, + expectedPatchedMetadataIntegrity: metadataIntegrity, + }); + expect(remediated).toMatchObject({ metadataIntegrity, remediated: true }); + const extracted = path.join(fixture.workingDirectory, "asserted"); + mkdirSync(extracted, { recursive: true }); + const extraction = spawnSync("tar", ["-xzf", remediated.archivePath, "-C", extracted], { + encoding: "utf-8", + }); + expect(extraction.status, extraction.stderr).toBe(0); + const packageJson = readJson<{ + bundledDependencies?: string[]; + dependencies?: Record; + }>(path.join(extracted, "package", "package.json")); + const fsSafePackageJson = readJson<{ optionalDependencies?: Record }>( + path.join(extracted, "package", "node_modules", "@openclaw", "fs-safe", "package.json"), + ); + expect(packageJson).toMatchObject({ + bundledDependencies: ["@openclaw/fs-safe"], + dependencies: { jszip: "3.10.1", tar: "7.5.19" }, + }); + expect(fsSafePackageJson.optionalDependencies).toBeUndefined(); + }); + + // source-shape-contract: security -- Extracted plugin contents prove the rebuilt archive carries every reviewed Axios replacement package + it("rebuilds a guarded plugin archive with the patched Axios graph bundled", () => { + const fixture = writePluginArchiveFixtures(); + const request = { + archivePath: fixture.archivePath, + env: { NEMOCLAW_REVIEWED_NPM_EXECUTABLE: fixture.npmExecutable }, + packageSpec: "@openclaw/slack@2026.6.10", + workingDirectory: fixture.workingDirectory, + }; + let metadataIntegrity = ""; + try { + buildRemediatedOpenClawArchive({ + ...request, + expectedPatchedMetadataIntegrity: "sha512-deliberate-mismatch", + }); + } catch (error) { + const message = String(error); + expect(message).toMatch(/got sha512-\S+/u); + metadataIntegrity = message.match(/got (sha512-\S+)/u)?.[1] ?? ""; + } + expect(metadataIntegrity).toMatch(/^sha512-/u); + + const remediated = buildRemediatedOpenClawArchive({ + ...request, + expectedPatchedMetadataIntegrity: metadataIntegrity, + }); + const extracted = path.join(fixture.workingDirectory, "asserted-plugin"); + mkdirSync(extracted, { recursive: true }); + const extraction = spawnSync("tar", ["-xzf", remediated.archivePath, "-C", extracted], { + encoding: "utf-8", + }); + expect(extraction.status, extraction.stderr).toBe(0); + const axiosPackageJson = readJson<{ name: string; version: string }>( + path.join(extracted, "package", "node_modules", "axios", "package.json"), + ); + const proxyPackageJson = readJson<{ name: string; version: string }>( + path.join( + extracted, + "package", + "node_modules", + "axios", + "node_modules", + "https-proxy-agent", + "package.json", + ), + ); + const agentBasePackageJson = readJson<{ name: string; version: string }>( + path.join( + extracted, + "package", + "node_modules", + "axios", + "node_modules", + "https-proxy-agent", + "node_modules", + "agent-base", + "package.json", + ), + ); + expect(axiosPackageJson).toEqual(expect.objectContaining({ name: "axios", version: "1.18.0" })); + expect(proxyPackageJson).toEqual( + expect.objectContaining({ name: "https-proxy-agent", version: "5.0.1" }), + ); + expect(agentBasePackageJson).toEqual( + expect.objectContaining({ name: "agent-base", version: "6.0.2" }), + ); + }); +}); 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..7310d07400c --- /dev/null +++ b/test/openclaw-2026-7-startup-compat.test.ts @@ -0,0 +1,186 @@ +// 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 { 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 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")); + assert(match, `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("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"); + 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); + + 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-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-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index e2d1a7c6b8b..2d2e60e3593 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -16,9 +16,15 @@ 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.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", @@ -38,6 +44,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", @@ -123,6 +134,36 @@ 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("records the active mcporter advisory remediations", () => { + const review = readFileSync(ACTIVE_DEPENDENCY_REVIEW, "utf-8"); + + expect(review).toContain("GHSA-9mqv-5hh9-4cgg"); + expect(review).toContain("@hono/node-server@^1.19.9"); + expect(review).toContain("`2.0.11`"); + expect(review).toContain("GHSA-v2hh-gcrm-f6hx"); + expect(review).toContain("fast-uri@^3.0.1"); + expect(review).toContain("`3.1.4`"); + }); + it("keeps advisor disposition evidence in the dependency review note", () => { const review = readFileSync(DEPENDENCY_REVIEW, "utf-8"); @@ -232,12 +273,12 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { expect(review).toContain( "The mcporter locked graph reported no findings across `138` dependencies", ); + expect(review).toContain("GHSA-frvp-7c67-39w9"); + expect(review).toContain("Hono finding remains in the reviewed OpenClaw graph"); expect(review).toContain("GHSA-v422-hmwv-36x6"); expect(review).toContain("reviewed Slack and Microsoft Teams plugin graphs"); expect(review).toContain("GHSA-j3f2-48v5-ccww"); expect(review).toContain("reviewed diagnostics OTEL and WhatsApp plugin graphs"); - expect(review).toContain("GHSA-frvp-7c67-39w9"); - expect(review).toContain("reviewed OpenClaw graph"); expect(review).toContain("Node `v22.22.2`"); expect(review).toContain("public npm registry"); expect(review).toContain("engine requirement of `>=22.19.0`"); @@ -251,6 +292,9 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { expect(review).toContain("configured threshold in `ci/reviewed-npm-audit.json` is `high`"); expect(review).toContain("Transitive Dependency Graph Rationale"); expect(review).toContain("Transitive Remediation Boundary"); + expect(review).toContain("point-in-time record of the remediation shipped for the"); + expect(review).toContain("current 2026.7.1 path installs the reviewed core archive"); + expect(review).toContain("openclaw-2026.7.1-dependency-review.md"); expect(review).toContain("Transitive Remediation Concern Ledger"); expect(review).toContain("`openclaw@2026.6.10`, the helper makes these changes"); expect(review).toContain("`tar@7.5.16` with `tar@7.5.19`"); @@ -310,6 +354,7 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { ); expect(review).toContain("The other replacement tarballs include license files"); expect(review).toContain("`tar@7.5.19` declares BlueOak-1.0.0"); + expect(review).toContain("the OpenTelemetry packages declare Apache-2.0"); expect(review).toContain("the other packages declare MIT"); expect(review).toContain( "The OpenClaw 2026.6.10 bump does not newly introduce an unfrozen OpenClaw transitive graph", @@ -379,6 +424,20 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { expect(review).toContain("test/onboard-resume-provider-recovery.test.ts"); }); + // source-shape-contract: security -- The legacy archive remediation helper must be present in the base image before the fail-closed Docker build invokes it + it("copies the legacy OpenClaw remediation helper before the base build invokes it", () => { + const dockerfile = readFileSync(path.join(REPO_ROOT, "Dockerfile.base"), "utf-8"); + const helperCopy = dockerfile.indexOf( + "COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts", + ); + const helperInvocation = dockerfile.indexOf( + "node --experimental-strip-types /scripts/lib/openclaw-npm-remediation.mts", + ); + + expect(helperCopy).toBeGreaterThanOrEqual(0); + expect(helperInvocation).toBeGreaterThan(helperCopy); + }); + it("keeps every reviewed archive boundary on the shared invariant matrix (#5896)", () => { const result = spawnSync( "bash", @@ -429,24 +488,25 @@ 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" '/scripts/lib/openclaw-npm-remediation.mts' "$dockerfile remediation 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" check_contains "$openclaw_block" '"$OPENCLAW_PACK_PATH"' "$dockerfile local install path" - check_contains "$openclaw_block" 'OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_SOURCE_PACK_PATH")"' "$dockerfile pack directory" - check_contains "$openclaw_block" '--archive "$OPENCLAW_SOURCE_PACK_PATH" --package-spec "openclaw@\${OPENCLAW_VERSION}"' "$dockerfile remediated identity" + check_contains "$openclaw_block" 'OPENCLAW_PACK_DIR="$(dirname "$OPENCLAW_PACK_PATH")"' "$dockerfile pack directory" if [ "$dockerfile" = Dockerfile.base ]; then - check_contains "$openclaw_block" '[ ! -f "$OPENCLAW_SOURCE_PACK_PATH" ]' "$dockerfile archive path guard" + check_contains "$openclaw_block" '[ ! -f "$OPENCLAW_SOURCE_PACK_PATH" ]' "$dockerfile source archive path guard" fi + check_contains "$openclaw_block" '--archive "$OPENCLAW_SOURCE_PACK_PATH" --package-spec "openclaw@\${OPENCLAW_VERSION}"' "$dockerfile legacy remediated identity" + check_contains "$openclaw_block" 'if (!value.remediated || typeof value.archivePath !== "string")' "$dockerfile remediation result guard" check_contains "$openclaw_block" 'rm -rf "$OPENCLAW_PACK_DIR"' "$dockerfile cleanup" check_not_contains "$openclaw_block" 'REGISTRY_INTEGRITY=$(npm view' "$dockerfile inline integrity lookup" check_not_contains "$openclaw_block" 'pack_reviewed_npm_tarball' "$dockerfile inline pack helper" check_contains "$openclaw_block" 'openclaw-base-provenance-v1' "$dockerfile base provenance path" - check_contains "$openclaw_block" 'ignore-scripts+reviewed-lifecycle+transitive-remediation-v1' "$dockerfile base provenance recipe" - check_contains "$openclaw_block" 'npm ls -g --depth=1 openclaw @openclaw/fs-safe tar jszip' "$dockerfile installed remediation graph guard" + check_contains "$openclaw_block" "OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle-v1'" "$dockerfile direct provenance recipe" + check_contains "$openclaw_block" "OPENCLAW_RECIPE='ignore-scripts+reviewed-lifecycle+transitive-remediation-v1'" "$dockerfile remediated provenance recipe" + check_contains "$openclaw_block" '"recipe=\${OPENCLAW_RECIPE}"' "$dockerfile selected provenance recipe" check_contains "$openclaw_block" 'mcporter-package=mcporter@' "$dockerfile mcporter provenance package" check_contains "$openclaw_block" 'mcporter-integrity=' "$dockerfile mcporter provenance integrity" check_contains "$openclaw_block" 'mcporter-lock-sha256=' "$dockerfile mcporter provenance lock hash" @@ -469,9 +529,14 @@ 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" 'plugin_install_archive="$plugin_archive"' "optional plugin default archive" check_contains "$optional_plugin_block" 'openclaw plugins install "npm-pack:\${plugin_install_archive}"' "optional plugin npm-pack install" -check_contains "$optional_plugin_block" '--archive "$plugin_archive" --package-spec "$plugin_spec"' "optional plugin remediated identity" -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" @@ -479,14 +544,32 @@ check_not_contains "$optional_plugin_block" 'pack_reviewed_npm_tarball' "optiona grep -Fq 'rmSync(packed.rootDir, { recursive: true, force: true })' "$messaging_build_applier" grep -Fq 'from "../../../../../scripts/lib/reviewed-npm-archive.mts"' "$messaging_build_applier" grep -Fq 'from "../../../../../scripts/lib/openclaw-npm-remediation.mts"' "$messaging_build_applier" - grep -Fq 'remediateReviewedOpenClawArchive({' "$messaging_build_applier" + grep -Fq 'remediateReviewedOpenClawPluginArchive({' "$messaging_build_applier" grep -Fq 'spawnSync(request.npmExecutable ?? "npm", args' "$reviewed_archive_helper" grep -Fq '["view", request.packageSpec, "dist.integrity"]' "$reviewed_archive_helper" grep -Fq '["view", request.packageSpec, "dist.tarball"]' "$reviewed_archive_helper" grep -Fq '["pack", request.tarballUrl, "--pack-destination", rootDirectory, "--json"]' "$reviewed_archive_helper" grep -Fq 'reported unsafe archive filename' "$reviewed_archive_helper" + grep -Fq 'expectedPatchedTreeIntegrity' "$remediation_helper" grep -Fq 'expectedPatchedMetadataIntegrity' "$remediation_helper" + grep -Fq 'hashPackageTree' "$remediation_helper" + grep -Fq 'patchOpenClawCorePackageGraph' "$remediation_helper" + grep -Fq 'patchOpenClawDiagnosticsPackageGraph' "$remediation_helper" + for package_spec in \ + 'openclaw@2026.3.11' \ + 'openclaw@2026.6.10' \ + '@openclaw/diagnostics-otel@2026.6.10' \ + '@openclaw/slack@2026.6.10' \ + '@openclaw/msteams@2026.6.10' \ + '@openclaw/diagnostics-otel@2026.7.1' \ + '@openclaw/slack@2026.7.1' \ + '@openclaw/msteams@2026.7.1'; do + grep -Fq "$package_spec" "$remediation_helper" + done 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)} @@ -504,6 +587,16 @@ 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 '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 + 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-device-self-approval-patch.test.ts b/test/openclaw-device-self-approval-patch.test.ts index 5b354b9e62d..d8c5b76c91a 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 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("3 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(3); + 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,137 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { } }); + it("retains a stored CLI identity while preserving bootstrap and backend shared auth", () => { + 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; + setStoredOperatorDeviceAuthToken(value: boolean): void; + shouldOmitDeviceIdentityForGatewayCall(params: Record): boolean; + }>( + source, + "({ setForceDevicePairing, setStoredOperatorDeviceAuthToken, shouldOmitDeviceIdentityForGatewayCall })", + ); + const cliParams = { + authMode: "token", + opts: { clientName: "cli", mode: "cli" }, + token: "loopback-token", + url: "ws://127.0.0.1:18789", + }; + const backendParams = { + authMode: "token", + opts: { clientName: "gateway-client", mode: "backend" }, + token: "loopback-token", + url: "ws://127.0.0.1:18789", + }; + + expect(runtime.shouldOmitDeviceIdentityForGatewayCall(cliParams)).toBe(true); + runtime.setForceDevicePairing(true); + expect(runtime.shouldOmitDeviceIdentityForGatewayCall(cliParams)).toBe(false); + runtime.setForceDevicePairing(false); + runtime.setStoredOperatorDeviceAuthToken(true); + expect(runtime.shouldOmitDeviceIdentityForGatewayCall(cliParams)).toBe(false); + runtime.setStoredOperatorDeviceAuthToken(false); + expect(runtime.shouldOmitDeviceIdentityForGatewayCall(cliParams)).toBe(true); + expect(runtime.shouldOmitDeviceIdentityForGatewayCall(backendParams)).toBe(true); + expect( + runtime.shouldOmitDeviceIdentityForGatewayCall({ + ...backendParams, + url: "wss://gateway.example.test", + }), + ).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("upgrades a force-only patched local base with stored-device identity retention", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-identity-upgrade-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist); + writeFixtureDist(dist); + try { + const callFile = path.join(dist, "call-fixture.js"); + const source = fs.readFileSync(callFile, "utf8"); + const target = [ + "function shouldOmitDeviceIdentityForGatewayCall(params) {", + "\tconst mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI;", + ].join("\n"); + const forceOnly = [ + "function shouldOmitDeviceIdentityForGatewayCall(params) {", + '\tif (process.env.NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING === "1") return false; // nemoclaw: force device identity for loopback pairing bootstrap (#4462)', + "\tconst mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI;", + ].join("\n"); + expect(source).toContain(target); + fs.writeFileSync(callFile, source.replace(target, forceOnly)); + + const apply = runPatch(dist); + expect(apply.status, `${apply.stdout}${apply.stderr}`).toBe(0); + const patched = fs.readFileSync(callFile, "utf8"); + expect(patched.match(/force device identity for loopback pairing bootstrap/gu)).toHaveLength( + 1, + ); + expect( + patched.match( + /retain stored CLI device identity for loopback shared-token scope enforcement/gu, + ), + ).toHaveLength(1); + expect(runPatch(dist).status).toBe(0); + } 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"); + 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 e497fdb6589..c710c884945 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, @@ -39,11 +39,15 @@ const REVIEWED_NPM_ARCHIVE_HELPER = path.join( "lib", "reviewed-npm-archive.mts", ); -const UNPINNED_OPENCLAW_VERSION = "2026.6.11"; -const PINNED_OPENCLAW_VERSION = "2026.6.10"; +const UNPINNED_OPENCLAW_VERSION = "2026.7.2"; +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_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"; @@ -64,21 +68,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"; @@ -99,7 +103,7 @@ function openClawBaseProvenance( tarball = PINNED_OPENCLAW_TARBALL, ): string { const recipe = - version === PINNED_OPENCLAW_VERSION || version === LEGACY_REBUILD_OPENCLAW_VERSION + version === LEGACY_REBUILD_OPENCLAW_VERSION ? "ignore-scripts+reviewed-lifecycle+transitive-remediation-v1" : "ignore-scripts+reviewed-lifecycle-v1"; return [ @@ -234,7 +238,7 @@ function runInstallBlock( "const value = (name) => args[args.indexOf(name) + 1];", 'const output = path.join(value("--working-directory"), "openclaw-remediated.tgz");', 'fs.copyFileSync(value("--archive"), output);', - "console.log(output);", + "console.log(JSON.stringify({ archivePath: output, remediated: true }));", "", ].join("\n"), ); @@ -260,8 +264,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)}`, @@ -391,7 +395,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 remediationHelper = path.join(tmp, "openclaw-npm-remediation.cjs"); + const remediationFixture = path.join(tmp, "remediation-fixture.cjs"); fs.writeFileSync( reviewedNpmExecutable, [ @@ -423,26 +427,32 @@ function runOptionalOpenClawPluginBlock( { mode: 0o755 }, ); fs.writeFileSync( - remediationHelper, + remediationFixture, [ - 'const fs = require("node:fs");', - 'const path = require("node:path");', + "const fs = require('node:fs');", + "const path = require('node:path');", + `const log = ${JSON.stringify(log)};`, "const args = process.argv.slice(2);", - "const value = (name) => args[args.indexOf(name) + 1];", - `fs.appendFileSync(${JSON.stringify(log)}, \`remediate \${args.join(" ")}\\n\`);`, - 'const output = path.join(value("--working-directory"), "diagnostics-otel-2026.6.10-remediated.tgz");', - 'fs.copyFileSync(value("--archive"), output);', - "console.log(output);", + "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", `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)}`, @@ -475,7 +485,7 @@ function runOptionalOpenClawPluginBlock( "}", command .replaceAll("/scripts/lib/reviewed-npm-archive.mts", REVIEWED_NPM_ARCHIVE_HELPER) - .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationHelper), + .replaceAll("/scripts/lib/openclaw-npm-remediation.mts", remediationFixture), ].join("\n"); const scriptPath = path.join(tmp, "run.sh"); fs.writeFileSync(scriptPath, script, { mode: 0o700 }); @@ -491,25 +501,28 @@ 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); 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); - 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); @@ -531,12 +544,13 @@ 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("`13` moderate"); expect(reviewNote).toContain("`0` high"); expect(reviewNote).toContain("`0` critical"); - expect(reviewNote).toContain("`767` total dependencies"); + expect(reviewNote).toContain("`823` total dependencies"); expect(reviewNote).toContain( "`dist/pipeline.runtime-*.js`, which exports `prepareSlackMessage`", ); @@ -557,6 +571,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.", @@ -564,7 +581,28 @@ 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 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( (agentPackage) => @@ -620,7 +658,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(); @@ -661,16 +699,14 @@ 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( - /remediate --archive \S*\/diagnostics-otel-2026\.6\.10\.tgz --package-spec @openclaw\/diagnostics-otel@2026\.6\.10 --working-directory \S+\n/, + /openclaw plugins install npm-pack:\S*\/diagnostics-otel-2026\.7\.1\.tgz\n/, ); - expect(calls).toMatch( - /openclaw plugins install npm-pack:\S*\/diagnostics-otel-2026\.6\.10-remediated\.tgz\n/, - ); - expect(calls).not.toMatch( - /openclaw plugins install npm-pack:\S*\/diagnostics-otel-2026\.6\.10\.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`, @@ -679,10 +715,10 @@ 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/, + /openclaw plugins install npm-pack:\S*\/brave-plugin-2026\.7\.1\.tgz\n/, ); expect(calls).toContain("openclaw-env true true"); }); @@ -709,7 +745,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, @@ -783,7 +819,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(production.result.status).toBe(0); expect(codexAcp.result.status).toBe(0); - expect(base.result.status, `${base.result.stdout}${base.result.stderr}`).toBe(0); + expect(base.result.status).toBe(0); expect(production.calls).toContain( `npm view openclaw@${PINNED_OPENCLAW_VERSION} dist.integrity`, ); @@ -819,21 +855,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(base.calls).toContain( "node /usr/local/lib/node_modules/openclaw/scripts/postinstall-bundled-plugins.mjs", ); - for (const calls of [production.calls, base.calls]) { - expect(calls).toContain("npm ls -g --depth=1 openclaw @openclaw/fs-safe tar jszip"); - } - for (const calls of [production.calls, base.calls]) { - const installCalls = calls - .split("\n") - .filter((call) => call.startsWith("npm install -g ")); - expect( - installCalls.some((call) => call.includes("/openclaw-remediated.tgz")), - calls, - ).toBe(true); - expect( - installCalls.some((call) => call.includes(`/openclaw-${PINNED_OPENCLAW_VERSION}.tgz`)), - ).toBe(false); - } + expect(base.calls).toContain(`openclaw-${PINNED_OPENCLAW_VERSION}.tgz`); expect(base.provenanceContent).toBe(openClawBaseProvenance()); expect(base.provenanceMode).toBe(0o444); }); @@ -914,7 +936,7 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes "wrong lifecycle recipe", { baseProvenance: openClawBaseProvenance().replace( - "recipe=ignore-scripts+reviewed-lifecycle+transitive-remediation-v1", + "recipe=ignore-scripts+reviewed-lifecycle-v1", "recipe=ignore-scripts-only-v1", ), }, @@ -1030,7 +1052,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(), @@ -1039,7 +1061,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`); @@ -1057,7 +1079,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( @@ -1083,18 +1105,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", }, { @@ -1106,13 +1128,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", }, ]) { @@ -1230,6 +1252,10 @@ export function registerOpenClawIntegrityPinTests(group: OpenClawIntegrityPinTes expect(fixtureBase.calls).toContain(`openclaw-${LEGACY_REBUILD_OPENCLAW_VERSION}.tgz`); expect(fixtureBase.calls).toContain("npm install -g --ignore-scripts "); expect(fixtureBase.calls).toContain("openclaw-remediated.tgz"); + expect(fixtureBase.calls).not.toContain('"archivePath"'); + expect(fixtureBase.calls).toMatch( + /npm install -g --ignore-scripts \S+\/openclaw-remediated\.tgz/u, + ); expect(fixtureBase.calls).not.toContain("postinstall-bundled-plugins.mjs"); expect(gatewayFixtureBase.result.status).toBe(0); expect(gatewayFixtureBase.calls).toContain("npm install -g --ignore-scripts "); @@ -1315,10 +1341,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 c6dad616ef9..2123034d2cb 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 index ff5a35d0813..15d97609f04 100644 --- a/test/openclaw-npm-remediation.test.ts +++ b/test/openclaw-npm-remediation.test.ts @@ -10,6 +10,7 @@ import { mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -17,8 +18,9 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { buildRemediatedOpenClawArchive, + hashPackageTree, patchLegacyOpenClawCorePackageGraph, - patchOpenClawCorePackageGraph, + patchOpenClawDiagnosticsOtelPackageGraph, patchOpenClawPluginPackageGraph, } from "../scripts/lib/openclaw-npm-remediation.mts"; @@ -32,7 +34,7 @@ function writeFixture(axiosVersion = "1.16.0"): string { `${JSON.stringify( { name: "@openclaw/slack", - version: "2026.6.10", + version: "2026.7.1", dependencies: { "@slack/bolt": "4.7.3" }, bundledDependencies: ["@slack/bolt"], }, @@ -45,13 +47,13 @@ function writeFixture(axiosVersion = "1.16.0"): string { `${JSON.stringify( { name: "@openclaw/slack", - version: "2026.6.10", + version: "2026.7.1", lockfileVersion: 3, requires: true, packages: { "": { name: "@openclaw/slack", - version: "2026.6.10", + version: "2026.7.1", dependencies: { "@slack/bolt": "4.7.3" }, }, "node_modules/axios": { @@ -73,21 +75,22 @@ function writeFixture(axiosVersion = "1.16.0"): string { return directory; } -function writeCoreFixture(tarVersion = "7.5.16"): string { - const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-core-remediation-")); +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: "openclaw", - version: "2026.6.10", - dependencies: { - "@openclaw/fs-safe": "0.3.0", - jszip: "3.10.1", - minimatch: "10.2.5", - tar: tarVersion, - }, + name: "@opentelemetry/sdk-node", + version: "0.219.0", + dependencies: { "@opentelemetry/propagator-jaeger": jaegerVersion }, }, null, 2, @@ -97,43 +100,18 @@ function writeCoreFixture(tarVersion = "7.5.16"): string { path.join(directory, "npm-shrinkwrap.json"), `${JSON.stringify( { - name: "openclaw", - version: "2026.6.10", + name: "@openclaw/diagnostics-otel", + version: "2026.7.1", lockfileVersion: 3, packages: { - "": { - name: "openclaw", - version: "2026.6.10", - dependencies: { - "@openclaw/fs-safe": "0.3.0", - jszip: "3.10.1", - minimatch: "10.2.5", - tar: tarVersion, - }, - }, - "node_modules/@openclaw/fs-safe": { - version: "0.3.0", - optionalDependencies: { jszip: "^3.10.1", tar: "7.5.13" }, + "": { name: "@openclaw/diagnostics-otel", version: "2026.7.1" }, + "node_modules/@opentelemetry/sdk-node": { + version: "0.219.0", + dependencies: { "@opentelemetry/propagator-jaeger": jaegerVersion }, }, - "node_modules/brace-expansion": { - version: "5.0.6", - resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - integrity: "sha512-old-brace-expansion", - }, - "node_modules/minimatch": { - version: "10.2.5", - dependencies: { "brace-expansion": "^5.0.5" }, - }, - "node_modules/jszip": { - version: "3.10.1", - resolved: "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - integrity: - "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - }, - "node_modules/tar": { - version: tarVersion, - resolved: `https://registry.npmjs.org/tar/-/tar-${tarVersion}.tgz`, - integrity: "sha512-old-tar", + "node_modules/@opentelemetry/propagator-jaeger": { + version: jaegerVersion, + dependencies: { "@opentelemetry/core": jaegerVersion }, }, }, }, @@ -176,65 +154,13 @@ function packFixture(packageDirectory: string, archivePath: string): void { expect(result.status, result.stderr || "failed to pack OpenClaw test archive").toBe(0); } -function writeCoreArchiveFixtures(): { - archivePath: string; - npmExecutable: string; - workingDirectory: string; -} { - const root = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-build-remediation-")); - temporaryDirectories.push(root); - const archivePath = path.join(root, "openclaw-2026.6.10.tgz"); - packFixture(writeCoreFixture(), archivePath); - - const fsSafeDirectory = path.join(root, "fs-safe-package"); - mkdirSync(fsSafeDirectory, { recursive: true }); - writeFileSync( - path.join(fsSafeDirectory, "package.json"), - `${JSON.stringify( - { - name: "@openclaw/fs-safe", - version: "0.3.0", - optionalDependencies: { jszip: "^3.10.1", tar: "7.5.13" }, - }, - null, - 2, - )}\n`, - ); - const fsSafeArchive = path.join(root, "fs-safe-0.3.0-source.tgz"); - packFixture(fsSafeDirectory, fsSafeArchive); - - const npmExecutable = path.join(root, "npm-fixture.sh"); - writeFileSync( - npmExecutable, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `fs_safe_archive=${JSON.stringify(fsSafeArchive)}`, - 'if [ "$1" = "view" ] && [ "$3" = "dist.integrity" ]; then', - ' printf "%s\\n" "sha512-uIBE441CIt1kIURoP9qRGKZ8LkGyfD9ZzeESjwAd29ZPWtghws/5GR3Pjb67jKdcJHP1I6roNXcvnhzAU7lHlA=="', - " exit 0", - "fi", - 'if [ "$1" = "view" ] && [ "$3" = "dist.tarball" ]; then', - ' printf "%s\\n" "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.3.0.tgz"', - " exit 0", - "fi", - 'if [ "$1" = "pack" ]; then', - ' destination=""', - ' while [ "$#" -gt 0 ]; do', - ' if [ "$1" = "--pack-destination" ]; then destination="$2"; shift 2; continue; fi', - " shift", - " done", - ' cp "$fs_safe_archive" "$destination/fs-safe-0.3.0.tgz"', - ' printf \'[{"filename":"fs-safe-0.3.0.tgz","integrity":"sha512-uIBE441CIt1kIURoP9qRGKZ8LkGyfD9ZzeESjwAd29ZPWtghws/5GR3Pjb67jKdcJHP1I6roNXcvnhzAU7lHlA=="}]\\n\'', - " exit 0", - "fi", - "exit 1", - "", - ].join("\n"), - { mode: 0o700 }, - ); - chmodSync(npmExecutable, 0o700); - return { archivePath, npmExecutable, workingDirectory: path.join(root, "work") }; +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; } function writeLegacyCoreArchiveFixtures(): { @@ -285,94 +211,6 @@ function writeLegacyCoreArchiveFixtures(): { return { archivePath, npmExecutable, workingDirectory: path.join(root, "work") }; } -function writePluginArchiveFixtures(): { - archivePath: string; - npmExecutable: string; - workingDirectory: string; -} { - const root = mkdtempSync(path.join(tmpdir(), "nemoclaw-openclaw-plugin-remediation-")); - temporaryDirectories.push(root); - const archivePath = path.join(root, "slack-2026.6.10.tgz"); - packFixture(writeFixture(), archivePath); - - const replacements = [ - { - archive: "axios-1.18.0-source.tgz", - dependencies: { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0", - }, - name: "axios", - version: "1.18.0", - }, - { - archive: "https-proxy-agent-5.0.1-source.tgz", - dependencies: { "agent-base": "6", debug: "4" }, - name: "https-proxy-agent", - version: "5.0.1", - }, - { - archive: "agent-base-6.0.2-source.tgz", - dependencies: { debug: "4" }, - name: "agent-base", - version: "6.0.2", - }, - ] as const; - for (const replacement of replacements) { - const directory = path.join(root, `${replacement.name}-package`); - mkdirSync(directory, { recursive: true }); - writeFileSync( - path.join(directory, "package.json"), - `${JSON.stringify( - { - dependencies: replacement.dependencies, - name: replacement.name, - version: replacement.version, - }, - null, - 2, - )}\n`, - ); - packFixture(directory, path.join(root, replacement.archive)); - } - - const npmExecutable = path.join(root, "npm-fixture.sh"); - writeFileSync( - npmExecutable, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `fixture_root=${JSON.stringify(root)}`, - 'case "$1:$2:${3:-}" in', - ' "view:axios@1.18.0:dist.integrity") value="sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==" ;;', - ' "view:axios@1.18.0:dist.tarball") value="https://registry.npmjs.org/axios/-/axios-1.18.0.tgz" ;;', - ' "view:https-proxy-agent@5.0.1:dist.integrity") value="sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==" ;;', - ' "view:https-proxy-agent@5.0.1:dist.tarball") value="https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz" ;;', - ' "view:agent-base@6.0.2:dist.integrity") value="sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==" ;;', - ' "view:agent-base@6.0.2:dist.tarball") value="https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" ;;', - ' "pack:https://registry.npmjs.org/axios/-/axios-1.18.0.tgz:--pack-destination") archive="axios-1.18.0-source.tgz"; filename="axios-1.18.0.tgz"; integrity="sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==" ;;', - ' "pack:https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz:--pack-destination") archive="https-proxy-agent-5.0.1-source.tgz"; filename="https-proxy-agent-5.0.1.tgz"; integrity="sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==" ;;', - ' "pack:https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz:--pack-destination") archive="agent-base-6.0.2-source.tgz"; filename="agent-base-6.0.2.tgz"; integrity="sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==" ;;', - ' *) echo "unexpected npm fixture invocation: $*" >&2; exit 1 ;;', - "esac", - 'if [ "$1" = "view" ]; then printf "%s\\n" "$value"; exit 0; fi', - 'destination=""', - 'while [ "$#" -gt 0 ]; do', - ' if [ "$1" = "--pack-destination" ]; then destination="$2"; shift 2; continue; fi', - " shift", - "done", - 'cp "$fixture_root/$archive" "$destination/$filename"', - 'printf \'[{"filename":"%s","integrity":"%s"}]\\n\' "$filename" "$integrity"', - "", - ].join("\n"), - { mode: 0o700 }, - ); - chmodSync(npmExecutable, 0o700); - return { archivePath, npmExecutable, workingDirectory: path.join(root, "work") }; -} - afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }); @@ -380,21 +218,60 @@ afterEach(() => { }); describe("OpenClaw npm remediation", () => { - // source-shape-contract: security -- Exact replacement metadata binds the rebuilt plugin archive to the reviewed registry identities + 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(); - patchOpenClawPluginPackageGraph(directory, "@openclaw/slack@2026.6.10"); + 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")); - const packageJson = readJson<{ - bundledDependencies?: string[]; - dependencies?: Record; - }>(path.join(directory, "package.json")); - expect(packageJson.dependencies).toMatchObject({ axios: "1.18.0" }); - expect(packageJson.bundledDependencies).toContain("axios"); expect(shrinkwrap.packages["node_modules/axios"]).toMatchObject({ version: "1.18.0", resolved: "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", @@ -425,58 +302,44 @@ describe("OpenClaw npm remediation", () => { it("rejects an upstream Axios graph that changed after review", () => { const directory = writeFixture("1.17.0"); - expect(() => patchOpenClawPluginPackageGraph(directory, "@openclaw/slack@2026.6.10")).toThrow( + expect(() => patchOpenClawPluginPackageGraph(directory, "@openclaw/slack@2026.7.1")).toThrow( "must resolve node_modules/axios to 1.16.0 before remediation", ); }); - // source-shape-contract: security -- Exact core shrinkwrap metadata binds remediation output to the reviewed registry identities - it("replaces the reviewed OpenClaw core tar and brace-expansion graph", () => { - const directory = writeCoreFixture(); + it("replaces the reviewed Jaeger propagator with its aligned patched core", () => { + const directory = writeDiagnosticsFixture(); - patchOpenClawCorePackageGraph(directory); + 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< - string, - { - dependencies?: Record; - integrity?: string; - optionalDependencies?: Record; - resolved?: string; - version?: string; - } - >; + packages: Record }>; }>(path.join(directory, "npm-shrinkwrap.json")); - const packageJson = readJson<{ - bundledDependencies?: string[]; - dependencies?: Record; - }>(path.join(directory, "package.json")); - expect(packageJson.dependencies).toMatchObject({ jszip: "3.10.1", tar: "7.5.19" }); - expect(packageJson.bundledDependencies).toEqual(["@openclaw/fs-safe"]); - expect(shrinkwrap.packages[""]).toMatchObject({ dependencies: { tar: "7.5.19" } }); - expect(shrinkwrap.packages["node_modules/tar"]).toMatchObject({ - version: "7.5.19", - resolved: "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - integrity: - "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + expect(shrinkwrap.packages["node_modules/@opentelemetry/propagator-jaeger"]).toMatchObject({ + version: "2.9.0", + dependencies: { "@opentelemetry/core": "2.9.0" }, }); - expect(shrinkwrap.packages["node_modules/@openclaw/fs-safe"]?.optionalDependencies).toBe( - undefined, - ); - expect(shrinkwrap.packages["node_modules/brace-expansion"]).toMatchObject({ - version: "5.0.7", - resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - integrity: - "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + 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 an OpenClaw core tar graph that changed after review", () => { - const directory = writeCoreFixture("7.5.17"); + it("rejects a diagnostics Jaeger graph that changed after review", () => { + const directory = writeDiagnosticsFixture("2.8.1"); - expect(() => patchOpenClawCorePackageGraph(directory)).toThrow( - "must declare reviewed tar@7.5.16 before remediation", + expect(() => patchOpenClawDiagnosticsOtelPackageGraph(directory)).toThrow( + "with Jaeger propagator 2.8.0 before remediation", ); }); @@ -523,119 +386,4 @@ describe("OpenClaw npm remediation", () => { ), ).toMatchObject({ name: "tar", version: "7.5.19" }); }); - - // source-shape-contract: security -- Archive metadata proves the rebuilt package carries the reviewed bundled fs-safe remediation - it("rebuilds a guarded core archive with the patched fs-safe package bundled", () => { - const fixture = writeCoreArchiveFixtures(); - const request = { - archivePath: fixture.archivePath, - env: { NEMOCLAW_REVIEWED_NPM_EXECUTABLE: fixture.npmExecutable }, - packageSpec: "openclaw@2026.6.10", - workingDirectory: fixture.workingDirectory, - }; - let metadataIntegrity = ""; - try { - buildRemediatedOpenClawArchive({ - ...request, - expectedPatchedMetadataIntegrity: "sha512-deliberate-mismatch", - }); - } catch (error) { - const message = String(error); - expect(message).toMatch(/got sha512-\S+/u); - metadataIntegrity = message.match(/got (sha512-\S+)/u)?.[1] ?? ""; - } - expect(metadataIntegrity).toMatch(/^sha512-/u); - - const remediated = buildRemediatedOpenClawArchive({ - ...request, - expectedPatchedMetadataIntegrity: metadataIntegrity, - }); - expect(remediated).toMatchObject({ metadataIntegrity, remediated: true }); - const extracted = path.join(fixture.workingDirectory, "asserted"); - mkdirSync(extracted, { recursive: true }); - const extraction = spawnSync("tar", ["-xzf", remediated.archivePath, "-C", extracted], { - encoding: "utf-8", - }); - expect(extraction.status, extraction.stderr).toBe(0); - const packageJson = readJson<{ - bundledDependencies?: string[]; - dependencies?: Record; - }>(path.join(extracted, "package", "package.json")); - const fsSafePackageJson = readJson<{ optionalDependencies?: Record }>( - path.join(extracted, "package", "node_modules", "@openclaw", "fs-safe", "package.json"), - ); - expect(packageJson).toMatchObject({ - bundledDependencies: ["@openclaw/fs-safe"], - dependencies: { jszip: "3.10.1", tar: "7.5.19" }, - }); - expect(fsSafePackageJson.optionalDependencies).toBeUndefined(); - }); - - // source-shape-contract: security -- Extracted plugin contents prove the rebuilt archive carries every reviewed Axios replacement package - it("rebuilds a guarded plugin archive with the patched Axios graph bundled", () => { - const fixture = writePluginArchiveFixtures(); - const request = { - archivePath: fixture.archivePath, - env: { NEMOCLAW_REVIEWED_NPM_EXECUTABLE: fixture.npmExecutable }, - packageSpec: "@openclaw/slack@2026.6.10", - workingDirectory: fixture.workingDirectory, - }; - let metadataIntegrity = ""; - try { - buildRemediatedOpenClawArchive({ - ...request, - expectedPatchedMetadataIntegrity: "sha512-deliberate-mismatch", - }); - } catch (error) { - const message = String(error); - expect(message).toMatch(/got sha512-\S+/u); - metadataIntegrity = message.match(/got (sha512-\S+)/u)?.[1] ?? ""; - } - expect(metadataIntegrity).toMatch(/^sha512-/u); - - const remediated = buildRemediatedOpenClawArchive({ - ...request, - expectedPatchedMetadataIntegrity: metadataIntegrity, - }); - const extracted = path.join(fixture.workingDirectory, "asserted-plugin"); - mkdirSync(extracted, { recursive: true }); - const extraction = spawnSync("tar", ["-xzf", remediated.archivePath, "-C", extracted], { - encoding: "utf-8", - }); - expect(extraction.status, extraction.stderr).toBe(0); - const axiosPackageJson = readJson<{ name: string; version: string }>( - path.join(extracted, "package", "node_modules", "axios", "package.json"), - ); - const proxyPackageJson = readJson<{ name: string; version: string }>( - path.join( - extracted, - "package", - "node_modules", - "axios", - "node_modules", - "https-proxy-agent", - "package.json", - ), - ); - const agentBasePackageJson = readJson<{ name: string; version: string }>( - path.join( - extracted, - "package", - "node_modules", - "axios", - "node_modules", - "https-proxy-agent", - "node_modules", - "agent-base", - "package.json", - ), - ); - expect(axiosPackageJson).toEqual(expect.objectContaining({ name: "axios", version: "1.18.0" })); - expect(proxyPackageJson).toEqual( - expect.objectContaining({ name: "https-proxy-agent", version: "5.0.1" }), - ); - expect(agentBasePackageJson).toEqual( - expect.objectContaining({ name: "agent-base", version: "6.0.2" }), - ); - }); }); 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..399840627c4 100644 --- a/test/openclaw-real-patched-dist-harness.test.ts +++ b/test/openclaw-real-patched-dist-harness.test.ts @@ -19,12 +19,18 @@ 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. 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; @@ -116,6 +122,53 @@ 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) ?? + 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 +273,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,9 +373,13 @@ 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_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); @@ -362,10 +448,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 +463,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 +491,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 +513,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 +532,161 @@ 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) => { + 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", + "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) + .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) => { + 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", + "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) + .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 reviewed OpenClaw dist 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..eeac52abac0 --- /dev/null +++ b/test/openclaw-shared-state-permissions-patch.test.ts @@ -0,0 +1,973 @@ +// 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, + MARKER, + MIGRATION_MARKER, + MODELS_MARKER, + patchOpenClawAgentDbText, + patchOpenClawModelsConfigText, + patchOpenClawSharedStatePermissions, + patchOpenClawStateDbText, + patchOpenClawStateMigrationText, +} from "../scripts/patch-openclaw-shared-state-permissions.mts"; +import { restoreEnv } from "./helpers/env-test-helpers"; + +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 upstreamSecret = fs.readFileSync(fixture.secretFiles[0], "utf8"); + const patchedMigration = fs.readFileSync(fixture.migrationFiles[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(patchedMigration.split(MIGRATION_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(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(upstreamSecret); + expect(fs.readFileSync(fixture.migrationFiles[0], "utf8")).toBe(patchedMigration); + 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 }); + } + }); + + it.each([ + "1", + "sandbox-name", + ])("retains owner-only state modes for the same-UID 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(0o700); + expect(mode(database)).toBe(0o600); + expect(mode(wal)).toBe(0o600); + } 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 { + restoreEnv("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.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; + 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: 0o700, mode: 0o600 }); + await runtime.enforcePrivatePathMode(privateDir, defaults.dirMode, "directory"); + expect(mode(privateDir)).toBe(0o700); + } finally { + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousMarker); + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform !== "linux" || (process.getuid?.() ?? -1) !== 0)( + "denies a same-group user access to another user's device identity", + async () => { + const fixture = makeFixture(); + try { + patchOpenClawSharedStatePermissions(fixture.dist); + const runtime = await importFileStoreFixture(fixture.fileStoreFiles[0]); + 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(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(identityFile)}, "gateway-credential", { mode: ${defaults.mode} });`, + ], + { encoding: "utf8", gid: sharedGid, uid: gatewayUid }, + ); + expect(writer.status, writer.stderr).toBe(0); + expect(mode(privateDir)).toBe(0o700); + expect(mode(identityFile)).toBe(0o600); + + const reader = spawnSync( + process.execPath, + ["-e", `require("node:fs").readFileSync(${JSON.stringify(identityFile)}, "utf8")`], + { encoding: "utf8", gid: sharedGid, uid: sandboxUid }, + ); + 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 { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }, + ); + + 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 { + 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: 0o700, mode: 0o600, 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 { + restoreEnv("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 { + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousShared); + restoreEnv("OPENSHELL_SANDBOX", previousOpenShell); + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + 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); + 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 }); + + 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 { + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousShared); + restoreEnv("OPENSHELL_SANDBOX", previousOpenShell); + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + }); + + 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 = openShellMarker; + 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 { + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousShared); + restoreEnv("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 { + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousShared); + restoreEnv("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 { + restoreEnv("NEMOCLAW_OPENCLAW_SHARED_STATE", previousShared); + restoreEnv("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 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(() => + patchOpenClawStateMigrationText( + `${UPSTREAM_STATE_MIGRATION_SOURCE}\n${MIGRATION_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/openclaw-state-file-repair.test.ts b/test/openclaw-state-file-repair.test.ts new file mode 100644 index 00000000000..ab7062de8d9 --- /dev/null +++ b/test/openclaw-state-file-repair.test.ts @@ -0,0 +1,89 @@ +// 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 [, 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( + 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/openclaw-tool-search-runtime-validator.test.ts b/test/openclaw-tool-search-runtime-validator.test.ts index 96cf663df6e..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); @@ -225,12 +226,14 @@ 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", + secondRuntimeFileName: "tool-search-fixture.js", + secondSource: RUNTIME_FIXTURE_SOURCE, + 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/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"], }); diff --git a/test/rebuild-shields-auto-unlock.test.ts b/test/rebuild-shields-auto-unlock.test.ts index bf6d540e402..fa89a5d959c 100644 --- a/test/rebuild-shields-auto-unlock.test.ts +++ b/test/rebuild-shields-auto-unlock.test.ts @@ -160,6 +160,7 @@ function createFixture(opts: { shieldsLocked: boolean }) { const fakeRoot = path.join(tmpDir, "fake-sandbox-root"); fs.mkdirSync(path.join(fakeRoot, "workspace"), { recursive: true }); fs.writeFileSync(path.join(fakeRoot, "workspace", "marker.txt"), "test"); + const deleteMarkerPath = path.join(tmpDir, "sandbox-deleted"); const lockStatePath = path.join(tmpDir, "config-lock-state.txt"); fs.writeFileSync(lockStatePath, opts.shieldsLocked ? "locked" : "unlocked"); @@ -176,12 +177,19 @@ function createFixture(opts: { shieldsLocked: boolean }) { fs.writeFileSync( path.join(tmpDir, "openshell"), `#!/usr/bin/env node +const fs = require("node:fs"); const a = process.argv.slice(2); +const deleteMarkerPath = ${JSON.stringify(deleteMarkerPath)}; const requiredFeatures = "request-body-credential-rewrite websocket-credential-rewrite allow_all_known_mcp_methods"; if (a[0]==="-V" || a[0]==="--version") { process.stdout.write("openshell 0.0.85\\n"); process.exit(0); } if (a[0]==="sandbox" && a[1]==="list") { process.stdout.write("${sandboxName}\\n"); process.exit(0); } if (a[0]==="sandbox" && a[1]==="ssh-config") { process.stdout.write("${sshConfig}\\n"); process.exit(0); } -if (a[0]==="sandbox" && a[1]==="delete") { process.exit(0); } +if (a[0]==="sandbox" && a[1]==="delete") { fs.writeFileSync(deleteMarkerPath, "deleted\\n"); process.exit(0); } +if (a[0]==="sandbox" && a[1]==="get") { + if (fs.existsSync(deleteMarkerPath)) { process.stderr.write("Error: sandbox ${sandboxName} not found\\n"); process.exit(1); } + process.stdout.write("Name: ${sandboxName}\\nPhase: Ready\\n"); + process.exit(0); +} if (a[0]==="policy" && a[1]==="get") { process.stdout.write("version: 1\\nnetwork_policies:\\n test: {}\\n"); process.exit(0); } if (a[0]==="policy" && a[1]==="set") { process.exit(0); } if (a[0]==="status") { process.stdout.write("Server Status\\n Gateway: nemoclaw\\n Status: Connected\\n"); process.exit(0); } @@ -386,13 +394,13 @@ function runRebuild(fixture: ReturnType) { NEMOCLAW_NO_CONNECT_HINT: "1", NO_COLOR: "1", }, - timeout: 30_000, + timeout: 90_000, }, ); } describe("rebuild auto-unlocks when shields are UP (#3113)", () => { - it("detects locked shields and prints auto-unlock notice", { timeout: 60_000 }, () => { + it("detects locked shields and prints auto-unlock notice", { timeout: 120_000 }, () => { const f = createFixture({ shieldsLocked: true }); const r = runRebuild(f); const output = (r.stdout || "") + (r.stderr || ""); @@ -415,9 +423,10 @@ describe("rebuild auto-unlocks when shields are UP (#3113)", () => { expect(output).toContain("Capturing current policy snapshot"); // Backup proceeds. expect(output).toContain("Backing up sandbox state"); + expect(output).not.toContain("OpenShell did not confirm that the sandbox is absent"); }); - it("skips auto-unlock when shields are not configured", { timeout: 60_000 }, () => { + it("skips auto-unlock when shields are not configured", { timeout: 120_000 }, () => { const f = createFixture({ shieldsLocked: false }); const r = runRebuild(f); const output = (r.stdout || "") + (r.stderr || ""); @@ -429,5 +438,6 @@ describe("rebuild auto-unlocks when shields are UP (#3113)", () => { expect(output).not.toContain("Shields are UP"); expect(output).not.toContain("temporarily unlocking for rebuild backup"); expect(output).toContain("Backing up sandbox state"); + expect(output).not.toContain("OpenShell did not confirm that the sandbox is absent"); }); }); diff --git a/test/repro-2201.test.ts b/test/repro-2201.test.ts index bcda4256058..85e3af4c18c 100644 --- a/test/repro-2201.test.ts +++ b/test/repro-2201.test.ts @@ -210,6 +210,7 @@ function createFixture({ ); const sandboxName = rebuildTarget.name; + const deleteMarker = path.join(tmpDir, "sandbox-deleted"); // ── Dummy workspace dir for the fake ssh tar call ───────────── const workspaceDir = path.join(tmpDir, "fake-sandbox-root", "workspace"); @@ -229,7 +230,9 @@ function createFixture({ fs.writeFileSync( path.join(tmpDir, "openshell"), `#!/usr/bin/env node +const fs = require("node:fs"); const a = process.argv.slice(2); +const deleteMarker = ${JSON.stringify(deleteMarker)}; const requiredFeatures = "request-body-credential-rewrite websocket-credential-rewrite allow_all_known_mcp_methods"; if (a[0]==="-V" || a[0]==="--version") { process.stdout.write("openshell 0.0.85\\n"); process.exit(0); } if (a[0]==="status") { process.stdout.write("Server Status\\n Gateway: nemoclaw\\n Status: Connected\\n"); process.exit(0); } @@ -238,7 +241,12 @@ if (a[0]==="gateway" && a[1]==="select") { process.exit(0); } if (a[0]==="inference" && a[1]==="get") { process.stdout.write("Gateway inference:\\n Provider: p\\n Model: m\\n"); process.exit(0); } if (a[0]==="sandbox" && a[1]==="list") { process.stdout.write("${sandboxName}\\n"); process.exit(0); } if (a[0]==="sandbox" && a[1]==="ssh-config") { process.stdout.write("${sshConfig}\\n"); process.exit(0); } -if (a[0]==="sandbox" && a[1]==="delete") { process.exit(0); } +if (a[0]==="sandbox" && a[1]==="delete") { fs.writeFileSync(deleteMarker, "deleted\\n"); process.exit(0); } +if (a[0]==="sandbox" && a[1]==="get") { + if (fs.existsSync(deleteMarker)) { process.stderr.write("Error: sandbox ${sandboxName} not found\\n"); process.exit(1); } + process.stdout.write("Name: ${sandboxName}\\nPhase: Ready\\n"); + process.exit(0); +} process.exit(0); `, { mode: 0o755 }, @@ -370,7 +378,7 @@ function runRebuild(fixture: ReturnType) { NEMOCLAW_NO_CONNECT_HINT: "1", NO_COLOR: "1", }, - timeout: 50_000, + timeout: 90_000, }, ); } @@ -406,7 +414,7 @@ function readSessionMessagingPlan( describe("rebuild syncs agent from registry instead of a stale session (#2201)", () => { it("rebuild openclaw after hermes was onboarded last (reporter scenario)", { - timeout: 60_000, + timeout: 120_000, }, () => { // Exact scenario from the bug report: user has openclaw + hermes, // hermes was onboarded last, then runs `nemoclaw openclaw rebuild`. @@ -421,7 +429,7 @@ describe("rebuild syncs agent from registry instead of a stale session (#2201)", }); it("rebuild hermes after openclaw was onboarded last (reverse scenario)", { - timeout: 60_000, + timeout: 120_000, }, () => { const f = createFixture({ rebuildTarget: { name: "hermes", agent: "hermes" }, @@ -434,7 +442,7 @@ describe("rebuild syncs agent from registry instead of a stale session (#2201)", }); it("does not inherit messaging plan from a stale session for another sandbox", { - timeout: 60_000, + timeout: 120_000, }, () => { const f = createFixture({ rebuildTarget: { name: "openclaw", agent: null }, @@ -451,7 +459,7 @@ describe("rebuild syncs agent from registry instead of a stale session (#2201)", describe("rebuild forwards the stored --from Dockerfile to onboard (#2301)", () => { it("rebuild does not hit fromDockerfile conflict when session has a stored --from path", { - timeout: 60_000, + timeout: 120_000, }, () => { // Scenario: user onboarded with --from /path/to/Dockerfile, then // runs rebuild. Without the fix, onboard's conflict check sees diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 603f34b1120..3cd86309b5e 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -113,6 +113,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", "patch-bundled-npm-tar.mts")); writeFixture(path.join("scripts", "verify-wechat-runtime-lock.mts")); writeFixture(path.join("scripts", "lib", "reviewed-npm-archive.mts"), "fixture\n", 0o700); @@ -436,6 +437,15 @@ 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-bundled-npm-tar.mts"))).toBe(true); + expect( + fs.existsSync(path.join(buildCtx, "scripts", "checks", "node-tar-image-scan.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..10df24afacd 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("does not bake the split-user OpenClaw state marker into the runtime environment", () => { 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", "unset"]); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -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); 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; 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); diff --git a/tools/e2e/pr-e2e-gate.mts b/tools/e2e/pr-e2e-gate.mts index f5ec89ce0ba..519563ba419 100755 --- a/tools/e2e/pr-e2e-gate.mts +++ b/tools/e2e/pr-e2e-gate.mts @@ -985,7 +985,9 @@ function currentExactDiffCheck(checks: CheckRun[]): CheckRun | undefined { throw new Error("Multiple active PR gate checks exist for one PR/base SHA pair"); const history = ordered.slice(0, -1); if (history.some((check) => retryableFailureReason(check) === undefined)) { - throw new Error("PR gate history contains a non-retryable older check for one PR/base SHA pair"); + throw new Error( + "PR gate history contains a non-retryable older check for one PR/base SHA pair", + ); } const current = ordered.at(-1)!; if (active[0] && active[0].id !== current.id) { diff --git a/tools/e2e/runner-pressure-workflow-boundary.mts b/tools/e2e/runner-pressure-workflow-boundary.mts index f1491883929..ad30e94e0f1 100644 --- a/tools/e2e/runner-pressure-workflow-boundary.mts +++ b/tools/e2e/runner-pressure-workflow-boundary.mts @@ -76,7 +76,8 @@ export function validateRunnerPressureWorkflow(workflowValue: unknown): string[] !script.includes( 'classification_file="$E2E_ARTIFACT_DIR/runner-pressure-classification.jsonl"', ) || - script.indexOf('export E2E_TERMINAL_CLASSIFICATION_FILE="$classification_file"') >= liveTest || + script.indexOf('export E2E_TERMINAL_CLASSIFICATION_FILE="$classification_file"') >= + liveTest || !script.includes('test_status="$?"') || !script.includes('exit "$test_status"') ) { diff --git a/tools/e2e/runner-pressure.mts b/tools/e2e/runner-pressure.mts index 874965b8319..cea357cfa91 100644 --- a/tools/e2e/runner-pressure.mts +++ b/tools/e2e/runner-pressure.mts @@ -181,9 +181,13 @@ function assertEvidencePath(path: string | undefined, variableName: string): str /** Append one phase baseline without replacing the immutable workflow baseline. */ export function appendResourcePhaseBaseline(path: string, phase: string): void { const validatedPath = assertEvidencePath(path, "E2E_RESOURCE_PHASE_BASELINES_FILE"); - appendPrivateRegularFile(validatedPath, `${renderBaselineLine(collectResourceBaseline(phase))}\n`, { - maxBytes: PHASE_BASELINES_FILE_MAX_BYTES, - }); + appendPrivateRegularFile( + validatedPath, + `${renderBaselineLine(collectResourceBaseline(phase))}\n`, + { + maxBytes: PHASE_BASELINES_FILE_MAX_BYTES, + }, + ); } /** Create the trusted evidence files before PR-controlled live tests execute. */ @@ -201,10 +205,7 @@ function runInitializeEvidence(): void { process.env.E2E_TERMINAL_CLASSIFICATION_FILE, "E2E_TERMINAL_CLASSIFICATION_FILE", ); - writePrivateRegularFile( - baselinePath, - `${renderBaselineLine(collectResourceBaseline(phase))}\n`, - ); + writePrivateRegularFile(baselinePath, `${renderBaselineLine(collectResourceBaseline(phase))}\n`); writePrivateRegularFile(phaseBaselinesPath, ""); writePrivateRegularFile(classificationPath, ""); } diff --git a/tools/e2e/workflow-plan.mts b/tools/e2e/workflow-plan.mts index 9339ebc05c0..73d684afd79 100644 --- a/tools/e2e/workflow-plan.mts +++ b/tools/e2e/workflow-plan.mts @@ -147,9 +147,7 @@ function mapTrustedControllerBootstrapJob( return { ...selectors, jobs: jobs - .map((job) => - job === LEGACY_BOOTSTRAP_INSTALL_JOB_ID ? BOOTSTRAP_INSTALL_JOB_ID : job, - ) + .map((job) => (job === LEGACY_BOOTSTRAP_INSTALL_JOB_ID ? BOOTSTRAP_INSTALL_JOB_ID : job)) .join(","), }; }