diff --git a/.github/actions/ci-installer-hash-check/action.yaml b/.github/actions/ci-installer-hash-check/action.yaml new file mode 100644 index 00000000000..a6f96b160a1 --- /dev/null +++ b/.github/actions/ci-installer-hash-check/action.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Trusted installer hash check +description: Run the trusted installer hash verifier against an explicit repository tree. + +inputs: + repo-root: + description: Absolute path to the repository tree whose installer pins are being verified. + required: true + +runs: + using: composite + steps: + - name: Verify installer hashes are current + shell: bash + env: + NEMOCLAW_INSTALLER_HASH_REPO_ROOT: ${{ inputs.repo-root }} + run: bash "${{ github.action_path }}/../../../scripts/check-installer-hash.sh" diff --git a/.github/actions/ci-plugin-coverage/action.yaml b/.github/actions/ci-plugin-coverage/action.yaml index 2a93f8ed9d3..ddbb9e77f8b 100644 --- a/.github/actions/ci-plugin-coverage/action.yaml +++ b/.github/actions/ci-plugin-coverage/action.yaml @@ -29,6 +29,7 @@ runs: --coverage.reporter=cobertura \ --coverage.reportsDirectory=coverage/plugin \ --coverage.include="nemoclaw/src/**/*.ts" \ + --coverage.include="nemoclaw/src/**/*.cts" \ --coverage.exclude="**/*.test.ts" npx tsx scripts/check-coverage-ratchet.ts coverage/plugin/coverage-summary.json ci/coverage-threshold-plugin.json "Plugin coverage" diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 288b96b8fcc..4e4347de564 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -432,7 +432,7 @@ jobs: E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/openshell-gateway-auth-contract NEMOCLAW_RUN_LIVE_E2E: "1" NEMOCLAW_NON_INTERACTIVE: "1" - NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.71" + NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.72" DOCKER_GRPC_PROBE_IMAGE: "node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d" steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/.github/workflows/installer-hash-check.yaml b/.github/workflows/installer-hash-check.yaml index 49c53b5d8e0..a961b461884 100644 --- a/.github/workflows/installer-hash-check.yaml +++ b/.github/workflows/installer-hash-check.yaml @@ -2,8 +2,10 @@ # SPDX-License-Identifier: Apache-2.0 # # Verifies pinned installer SHA-256 hashes still match upstream scripts. -# Checked: Ollama installer. -# Runs on every PR and push to main, plus a weekly scheduled check. +# Checked: OpenShell v0.0.72 installer and Brev release assets. +# Reports the required network-backed drift check on every PR, every push to +# main, and weekly. Pull requests execute checker code from their base commit; +# the immutable bootstrap is used only for the PR that first adds that action. name: Security / Installer Hash Check @@ -26,8 +28,164 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Checkout + - name: Set up trusted installer hash parser runtime + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: 22.16.0 + + # The full PR-head checkout below supplies data only. Its checker and pin + # parser are never executed: later steps run exclusively from either + # .trusted-installer-hash or .bootstrap-installer-hash. + - name: Checkout pull request head + if: github.event_name == 'pull_request' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Checkout trusted event + if: github.event_name != 'pull_request' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Checkout base-trusted installer hash action + if: github.event_name == 'pull_request' uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: .trusted-installer-hash + persist-credentials: false + sparse-checkout: | + .github/actions/ci-installer-hash-check + scripts/check-installer-hash.sh + scripts/checks/extract-installer-pins.mts + sparse-checkout-cone-mode: false + + - name: Detect base-trusted installer hash action + id: trusted-installer-hash + if: github.event_name == 'pull_request' + shell: bash + run: | + if [[ -f .trusted-installer-hash/.github/actions/ci-installer-hash-check/action.yaml ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + fi + + # invalidState: the first PR that introduces this action has no copy in + # its base commit. Running the mutable PR-side checker would let that PR + # authorize its own installer pins. + # sourceBoundary: this exact commit and reviewed Git tree contain the + # trusted action and checker; the PR head supplies only inspected files. + # whyNotSourceFix: a base commit cannot contain a new action before the + # introducing PR merges, so the bootstrap must name immutable code once. + # regressionTest: test/pr-workflow-contract.test.ts rejects mutable + # checker execution, non-immutable refs, and a mismatched reviewed tree. + # manualReviewEvidence: on 2026-07-02, independent Git object inspection + # confirmed commit cb5e9aefab2b16fedc0995149fc3520da0d5e0c7 has + # tree 1fdf59efe40b78c407e222fd42043b23a61e199a. The reviewed bootstrap + # script SHA-256 is 179e1572932eedc1a8ed974d534e9f2a5c34db7ebe971000dc20b77ed9d9feb3; + # its parser SHA-256 is + # e1d6b63a7b0378a3d28ee71d347ade2da75b3fcf2ff55aa55a9b54d2bc2fc13a; + # and its composite-action SHA-256 is + # 9c48c64cc934032c99a0aa9aa08b1164757988dc2842e1df88d1b7252ce1183f. + # removalCondition: remove the bootstrap checkout after this workflow has + # landed on every supported PR base. The fallback is refused after the + # explicit 180-day review window ending 2026-12-29T19:35:41Z. + - name: Enforce immutable installer hash bootstrap expiry + if: >- + github.event_name == 'pull_request' && + steps.trusted-installer-hash.outputs.available != 'true' + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const commit = "cb5e9aefab2b16fedc0995149fc3520da0d5e0c7"; + const expiresAt = "2026-12-29T19:35:41Z"; + const expiresAtMs = Date.parse(expiresAt); + const canonicalExpiresAt = + Number.isFinite(expiresAtMs) && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.test(expiresAt) + ? new Date(expiresAtMs).toISOString().replace(".000Z", "Z") + : ""; + + if (!/^[a-f0-9]{40}$/u.test(commit) || canonicalExpiresAt !== expiresAt) { + console.error( + "::error::Immutable installer hash bootstrap expiry configuration is invalid; " + + "refusing the fallback. Expected a 40-character commit SHA and canonical UTC expiry.", + ); + process.exit(1); + } + + if (Date.now() >= expiresAtMs) { + console.error( + `::error::Immutable installer hash bootstrap ${commit} expired at ${expiresAt}. ` + + "Remove the bootstrap fallback or replace it with newly reviewed immutable checker code.", + ); + process.exit(1); + } + + const daysRemaining = Math.ceil((expiresAtMs - Date.now()) / 86_400_000); + console.log( + `Immutable installer hash bootstrap ${commit} remains valid for ${daysRemaining} day(s), ` + + `until ${expiresAt}.`, + ); + NODE + + - name: Checkout immutable installer hash bootstrap + if: >- + github.event_name == 'pull_request' && + steps.trusted-installer-hash.outputs.available != 'true' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: cb5e9aefab2b16fedc0995149fc3520da0d5e0c7 + path: .bootstrap-installer-hash + persist-credentials: false + sparse-checkout: | + .github/actions/ci-installer-hash-check + scripts/check-installer-hash.sh + scripts/checks/extract-installer-pins.mts + sparse-checkout-cone-mode: false + + - name: Verify immutable installer hash bootstrap tree + if: >- + github.event_name == 'pull_request' && + steps.trusted-installer-hash.outputs.available != 'true' + shell: bash + run: | + set -euo pipefail + readonly expected_commit="cb5e9aefab2b16fedc0995149fc3520da0d5e0c7" + readonly expected_tree="1fdf59efe40b78c407e222fd42043b23a61e199a" + actual_commit="$(git -C .bootstrap-installer-hash rev-parse HEAD)" + actual_tree="$(git -C .bootstrap-installer-hash rev-parse 'HEAD^{tree}')" + if [[ "${actual_commit}" != "${expected_commit}" ]]; then + echo "::error::Immutable installer hash bootstrap checkout does not match the reviewed commit." >&2 + exit 1 + fi + if [[ "${actual_tree}" != "${expected_tree}" ]]; then + echo "::error::Immutable installer hash bootstrap checkout does not match the reviewed tree." >&2 + exit 1 + fi + + - name: Verify pull request installer hashes from base-trusted code + if: >- + github.event_name == 'pull_request' && + steps.trusted-installer-hash.outputs.available == 'true' + uses: ./.trusted-installer-hash/.github/actions/ci-installer-hash-check + with: + repo-root: ${{ github.workspace }} + + - name: Verify pull request installer hashes from immutable bootstrap + if: >- + github.event_name == 'pull_request' && + steps.trusted-installer-hash.outputs.available != 'true' + uses: ./.bootstrap-installer-hash/.github/actions/ci-installer-hash-check + with: + repo-root: ${{ github.workspace }} - - name: Verify installer hashes are current - run: bash scripts/check-installer-hash.sh + - name: Verify trusted event installer hashes + if: github.event_name != 'pull_request' + uses: ./.github/actions/ci-installer-hash-check + with: + repo-root: ${{ github.workspace }} diff --git a/Dockerfile b/Dockerfile index af84f993e20..d9f0348ef95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,8 +22,13 @@ ENV NPM_CONFIG_AUDIT=false \ NPM_CONFIG_FETCH_TIMEOUT=300000 COPY nemoclaw/package.json nemoclaw/package-lock.json nemoclaw/tsconfig.json /opt/nemoclaw/ COPY nemoclaw/src/ /opt/nemoclaw/src/ +COPY scripts/checks/verify-openshell-policy-boundary-dependencies.mts /opt/nemoclaw-build-checks/ WORKDIR /opt/nemoclaw -RUN npm ci && npm run build +RUN npm ci \ + && npm run build \ + && node --experimental-strip-types \ + /opt/nemoclaw-build-checks/verify-openshell-policy-boundary-dependencies.mts \ + /opt/nemoclaw/dist/shared/openshell-policy-boundary.cjs # Stage 2: Build TypeScript messaging runtime preloads. FROM builder AS runtime-preload-builder @@ -101,9 +106,15 @@ ENV NPM_CONFIG_AUDIT=false \ NPM_CONFIG_FETCH_RETRY_MINTIMEOUT=20000 \ NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT=120000 \ NPM_CONFIG_FETCH_TIMEOUT=300000 +# The builder-stage verify-openshell-policy-boundary-dependencies.mts check is +# the primary security gate: it enforces the generated boundary's strict module +# dependency allowlist before this stage copies it. The node check below is +# defense in depth only and proves the copied runtime still exports the complete +# audited interface; function availability does not replace dependency lockdown. RUN npm ci --omit=dev \ && test -f /usr/local/bin/node \ && test -d /opt/nemoclaw/node_modules/json5 \ + && node -e 'const boundary = require("/opt/nemoclaw/dist/shared/openshell-policy-boundary.cjs"); for (const name of ["parseOpenShellPolicy", "stripProviderComposedPolicies", "withoutProviderComposedPolicies"]) { if (typeof boundary[name] !== "function") throw new Error("OpenShell policy boundary export is unavailable: " + name); }' \ && node_unsafe="$(find -L /usr/local/bin/node -maxdepth 0 \( ! -user root -o -perm /022 \) -print -quit)" \ && test -z "$node_unsafe" \ && json5_unsafe="$(find -L /opt/nemoclaw/node_modules/json5 \( ! -user root -o -perm /022 \) -print -quit)" \ diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 081313ffdcb..916cc2a9f85 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -12,6 +12,6 @@ "test/onboard-messaging.test.ts": 2062, "test/onboard-selection.test.ts": 6867, "test/onboard.test.ts": 4774, - "test/policies.test.ts": 2489 + "test/policies.test.ts": 2475 } } diff --git a/docs/about/release-notes.mdx b/docs/about/release-notes.mdx index cb1153c4dc2..470f339c968 100644 --- a/docs/about/release-notes.mdx +++ b/docs/about/release-notes.mdx @@ -16,6 +16,14 @@ NVIDIA NemoClaw is available in early preview starting March 16, 2026. Use this page to track the highlights of the latest release. For more detailed release notes, refer to the [NemoClaw GitHub announcements](https://github.com/NVIDIA/NemoClaw/discussions/categories/announcements?discussions_q=is%3Aopen+category%3AAnnouncements). +## v0.0.74 + +NemoClaw v0.0.74 advances to OpenShell `0.0.72` and adopts its safe policy round-trip boundary: + +- Stable installs pin OpenShell `0.0.72` release artifacts and supervisor image, adding MCP Streamable HTTP and JSON-RPC request-policy enforcement. +- Policy mutations now read the round-trippable base policy instead of the effective policy, preventing provider-composed `_provider_*` entries from being sent back through `policy set` while preserving existing MCP rules. + For more information, refer to [OpenShell 0.0.72 Compatibility Review](../security/openshell-0.0.72-compatibility-review) and [Customize the Network Policy](../network-policy/customize-network-policy). + ## v0.0.73 NemoClaw v0.0.73 improves custom endpoint safety, Linux GPU onboarding, agent-aware policy validation, upgrade recovery, LangChain Deep Agents Code inference, and operator documentation. diff --git a/docs/index.yml b/docs/index.yml index 8df00650280..f633b841c59 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -140,6 +140,9 @@ navigation: - page: "Credential Storage" path: _build/agent-variants/security/credential-storage.openclaw.generated.mdx slug: credential-storage + - page: "OpenShell 0.0.72 Compatibility Review" + path: _build/agent-variants/security/openshell-0.0.72-compatibility-review.openclaw.generated.mdx + slug: openshell-0.0.72-compatibility-review - page: "Trusted Computing Base" path: _build/agent-variants/security/tcb-boundary.openclaw.generated.mdx slug: trusted-computing-base @@ -298,6 +301,9 @@ navigation: - page: "Credential Storage" path: _build/agent-variants/security/credential-storage.hermes.generated.mdx slug: credential-storage + - page: "OpenShell 0.0.72 Compatibility Review" + path: _build/agent-variants/security/openshell-0.0.72-compatibility-review.hermes.generated.mdx + slug: openshell-0.0.72-compatibility-review - page: "Trusted Computing Base" path: _build/agent-variants/security/tcb-boundary.hermes.generated.mdx slug: trusted-computing-base diff --git a/docs/network-policy/customize-network-policy.mdx b/docs/network-policy/customize-network-policy.mdx index 78a12ded96f..8375c67effa 100644 --- a/docs/network-policy/customize-network-policy.mdx +++ b/docs/network-policy/customize-network-policy.mdx @@ -142,7 +142,8 @@ This path preserves existing policy entries and is the only NemoClaw-supported f $$nemoclaw my-assistant policy-add ``` -NemoClaw reads the live policy with `openshell policy get --full`, structurally merges your preset's `network_policies` into it, and writes the merged result back. +NemoClaw reads the round-trippable base policy with `openshell policy get --base`, structurally merges your preset's `network_policies` into it, and writes the merged result back. +Provider-composed `_provider_*` entries are excluded because OpenShell reserves that namespace and rejects it in `policy set`. Existing presets and the baseline remain in place. The preset file under `presets/` also persists across sandbox recreations. @@ -150,20 +151,20 @@ The preset file under `presets/` also persists across sandbox recreations. Use this path only when you cannot add a file under the NemoClaw source tree. Start from the current live policy so the presets layered on at onboarding stay in the file you apply. -Requires OpenShell 0.0.44+ for `policy get --full` and `policy set --wait` syntax. +Requires OpenShell 0.0.72+ for the round-trippable `policy get --base` and `policy set --wait` syntax. Strip the OpenShell metadata header before editing the file, then validate the raw policy shape before replacing your editable copy. The command order below matches the commands NemoClaw emits internally. ```bash # shellcheck shell=bash # Source-of-truth review: -# invalidState: OpenShell 0.0.44 policy get --full emits metadata before the --- YAML header. +# invalidState: OpenShell 0.0.72 policy get --base emits metadata before the --- YAML header. # sourceBoundary: OpenShell CLI output is owned by the separate OpenShell project. # whyNotSourceFix: NemoClaw pins OpenShell but cannot change that upstream formatter here. # regressionTest: test/policy-roundtrip-docs.test.ts validates this shared docs pattern. # removalCondition: remove this pipeline after pinned OpenShell emits clean raw YAML. tmp_policy=$(mktemp) -openshell policy get --full my-assistant \ +openshell policy get --base my-assistant \ | awk 'found { print } /^---$/ { found = 1 } END { if (!found) exit 1 }' \ > "$tmp_policy" \ && grep -q '^version:' "$tmp_policy" \ diff --git a/docs/network-policy/integration-policy-examples.mdx b/docs/network-policy/integration-policy-examples.mdx index 62f675ac24d..073d98c497f 100644 --- a/docs/network-policy/integration-policy-examples.mdx +++ b/docs/network-policy/integration-policy-examples.mdx @@ -368,18 +368,18 @@ $$nemoclaw my-assistant policy-list ``` Use OpenShell when you need an editable copy of the live policy. -Requires OpenShell 0.0.44+ for `policy get --full` and `policy set --wait` syntax. +Requires OpenShell 0.0.72+ for the round-trippable `policy get --base` and `policy set --wait` syntax. ```bash # shellcheck shell=bash # Source-of-truth review: -# invalidState: OpenShell 0.0.44 policy get --full emits metadata before the --- YAML header. +# invalidState: OpenShell 0.0.72 policy get --base emits metadata before the --- YAML header. # sourceBoundary: OpenShell CLI output is owned by the separate OpenShell project. # whyNotSourceFix: NemoClaw pins OpenShell but cannot change that upstream formatter here. # regressionTest: test/policy-roundtrip-docs.test.ts validates this shared docs pattern. # removalCondition: remove this pipeline after pinned OpenShell emits clean raw YAML. tmp_policy=$(mktemp) -openshell policy get --full my-assistant \ +openshell policy get --base my-assistant \ | awk 'found { print } /^---$/ { found = 1 } END { if (!found) exit 1 }' \ > "$tmp_policy" \ && grep -q '^version:' "$tmp_policy" \ diff --git a/docs/reference/cli-selection-guide.mdx b/docs/reference/cli-selection-guide.mdx index d17104011c6..158ed8e1e45 100644 --- a/docs/reference/cli-selection-guide.mdx +++ b/docs/reference/cli-selection-guide.mdx @@ -118,18 +118,18 @@ Use `openshell` when the docs explicitly call for a live OpenShell gateway opera - Inspect or replace raw OpenShell policy: - Requires OpenShell 0.0.44+ for `policy get --full` and `policy set --wait` syntax. + Requires OpenShell 0.0.72+ for the round-trippable `policy get --base` and `policy set --wait` syntax. ```bash # shellcheck shell=bash # Source-of-truth review: - # invalidState: OpenShell 0.0.44 policy get --full emits metadata before the --- YAML header. + # invalidState: OpenShell 0.0.72 policy get --base emits metadata before the --- YAML header. # sourceBoundary: OpenShell CLI output is owned by the separate OpenShell project. # whyNotSourceFix: NemoClaw pins OpenShell but cannot change that upstream formatter here. # regressionTest: test/policy-roundtrip-docs.test.ts validates this shared docs pattern. # removalCondition: remove this pipeline after pinned OpenShell emits clean raw YAML. tmp_policy=$(mktemp) - openshell policy get --full \ + openshell policy get --base \ | awk 'found { print } /^---$/ { found = 1 } END { if (!found) exit 1 }' \ > "$tmp_policy" \ && grep -q '^version:' "$tmp_policy" \ @@ -229,7 +229,8 @@ Use `$$nemoclaw policy-add` or `policy-remove` for NemoClaw presets and c NemoClaw merges the new policy with the live policy and reapplies presets during rebuilds. Use `openshell policy update` for precise live endpoint or REST rule changes. -Use `openshell policy get --full ` and `openshell policy set --policy --wait ` only when you need to edit and replace the raw policy file. +Use `openshell policy get --base ` and `openshell policy set --policy --wait ` only when you need to edit and replace the round-trippable base policy. +Use `--full` only to inspect the effective policy, including provider-composed rules. ### Move Workspace Files diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 95560286dc2..88541782f8b 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1910,7 +1910,7 @@ All ports must be non-privileged integers between 1024 and 65535. | Variable | Default | Service | |----------|---------|---------| | `NEMOCLAW_GATEWAY_PORT` | 8080 | OpenShell gateway port | -| `NEMOCLAW_GATEWAY_BIND_ADDRESS` | 127.0.0.1 | OpenShell gateway bind address. Docker-driver gateways on OpenShell 0.0.71 keep this on loopback while gateway JWT auth is active. | +| `NEMOCLAW_GATEWAY_BIND_ADDRESS` | 127.0.0.1 | The OpenShell gateway uses this bind address; Docker-driver gateways on OpenShell 0.0.72 keep it on loopback while gateway JWT auth is active. | | `NEMOCLAW_DASHBOARD_PORT` | 18789 (auto-derived from `CHAT_UI_URL` port if set) | Dashboard or API forward | | `NEMOCLAW_VLLM_PORT` | 8000 | vLLM / NIM inference | | `NEMOCLAW_OLLAMA_PORT` | 11434 | Ollama inference | @@ -1924,7 +1924,7 @@ When you run multiple NemoClaw gateways with different `NEMOCLAW_GATEWAY_PORT` v On non-WSL hosts, `NEMOCLAW_OLLAMA_PORT` and `NEMOCLAW_OLLAMA_PROXY_PORT` must be different. If you run Ollama on port 11435, set `NEMOCLAW_OLLAMA_PROXY_PORT` to another free port before onboarding. -`NEMOCLAW_GATEWAY_BIND_ADDRESS` accepts only `127.0.0.1` and `0.0.0.0`, but Docker-driver gateways on OpenShell 0.0.71 reject `0.0.0.0` while gateway JWT auth is active. +`NEMOCLAW_GATEWAY_BIND_ADDRESS` accepts only `127.0.0.1` and `0.0.0.0`, but Docker-driver gateways on OpenShell 0.0.72 reject `0.0.0.0` while gateway JWT auth is active. Keep the OpenShell gateway on loopback and use `NEMOCLAW_DASHBOARD_BIND` when you need remote browser/API access. `NEMOCLAW_DASHBOARD_BIND` controls the dashboard or API port forward bind address. @@ -2054,7 +2054,7 @@ Set them before running `nemohermes onboard`. | `NEMOCLAW_SANDBOX_GPU` | `auto`, `1`, or `0` | Controls sandbox GPU passthrough during onboarding. `auto` enables GPU passthrough when an NVIDIA GPU is detected, `1` requires GPU passthrough, and `0` forces CPU-only sandbox creation. | | `NEMOCLAW_SANDBOX_GPU_DEVICE` | OpenShell GPU device selector | Selects the GPU device passed with `openshell sandbox create --gpu-device`. Requires explicit sandbox GPU enablement with `NEMOCLAW_SANDBOX_GPU=1` (or `--sandbox-gpu` for CLI-driven onboarding); otherwise onboarding rejects the selector instead of treating it as an implicit opt-in. | | `NEMOCLAW_DOCKER_GPU_PATCH` | unset, `auto`, `1`, or `0` | Selects Linux Docker-driver GPU routing. Unset or `auto` uses native OpenShell GPU injection on ordinary native Linux and the compatibility patch on Docker Desktop WSL and Jetson/Tegra. `1` forces the compatibility patch. `0` selects native injection on ordinary native Linux and Jetson/Tegra, but Docker Desktop WSL ignores it. On Jetson/Tegra, use `0` only for troubleshooting because it bypasses the device-group propagation needed for CUDA. | -| `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH` | `1` to enable; disabled by default | Explicitly opts into the Linux gateway compatibility container for an older host ABI or a diagnostic run. This mode uses host networking and mounts the Docker socket read-only, but the socket still exposes the privileged Docker API. Use it only on a trusted local host; prefer OpenShell 0.0.71's directly supported glibc 2.28+ path. See the [OpenShell 0.0.71 gateway auth review](../security/openshell-0.0.71-gateway-auth-review#source-of-truth-boundaries). | +| `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH` | `1` to enable; disabled by default | This setting explicitly opts into the Linux gateway compatibility container for an older host ABI or a diagnostic run; use it only on a trusted local host because it uses host networking and mounts the Docker socket read-only even though the socket still exposes the privileged Docker API; prefer OpenShell 0.0.72's directly supported glibc 2.28+ path; see the [OpenShell 0.0.72 compatibility review](../security/openshell-0.0.72-compatibility-review#source-of-truth-boundaries) for details. | | `NEMOCLAW_OPENSHELL_GATEWAY_BIN` | path | Advanced override for the `openshell-gateway` binary used by the Linux Docker-driver standalone fallback. Defaults to the binary next to `openshell`, then common install paths. | | `NEMOCLAW_OPENSHELL_SANDBOX_BIN` | path | Advanced override for the `openshell-sandbox` binary used by the Linux Docker-driver standalone fallback. Defaults to the binary next to `openshell`, then common install paths. | | `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR` | path | Advanced override for the Linux Docker-driver gateway SQLite state directory and standalone-fallback PID file. Defaults to `~/.local/state/nemoclaw/openshell-docker-gateway`. | diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 386d1f49383..3acc0b5116c 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2297,7 +2297,7 @@ All ports must be non-privileged integers between 1024 and 65535. | Variable | Default | Service | |----------|---------|---------| | `NEMOCLAW_GATEWAY_PORT` | 8080 | OpenShell gateway port | -| `NEMOCLAW_GATEWAY_BIND_ADDRESS` | 127.0.0.1 | OpenShell gateway bind address. Docker-driver gateways on OpenShell 0.0.71 keep this on loopback while gateway JWT auth is active. | +| `NEMOCLAW_GATEWAY_BIND_ADDRESS` | 127.0.0.1 | The OpenShell gateway uses this bind address; Docker-driver gateways on OpenShell 0.0.72 keep it on loopback while gateway JWT auth is active. | | `NEMOCLAW_DASHBOARD_PORT` | 18789 (auto-derived from `CHAT_UI_URL` port if set) | Dashboard or API forward | | `NEMOCLAW_VLLM_PORT` | 8000 | vLLM / NIM inference | | `NEMOCLAW_OLLAMA_PORT` | 11434 | Ollama inference | @@ -2311,7 +2311,7 @@ When you run multiple NemoClaw gateways with different `NEMOCLAW_GATEWAY_PORT` v On non-WSL hosts, `NEMOCLAW_OLLAMA_PORT` and `NEMOCLAW_OLLAMA_PROXY_PORT` must be different. If you run Ollama on port 11435, set `NEMOCLAW_OLLAMA_PROXY_PORT` to another free port before onboarding. -`NEMOCLAW_GATEWAY_BIND_ADDRESS` accepts only `127.0.0.1` and `0.0.0.0`, but Docker-driver gateways on OpenShell 0.0.71 reject `0.0.0.0` while gateway JWT auth is active. +`NEMOCLAW_GATEWAY_BIND_ADDRESS` accepts only `127.0.0.1` and `0.0.0.0`, but Docker-driver gateways on OpenShell 0.0.72 reject `0.0.0.0` while gateway JWT auth is active. Keep the OpenShell gateway on loopback and use `NEMOCLAW_DASHBOARD_BIND` when you need remote browser/API access. `NEMOCLAW_DASHBOARD_BIND` controls the dashboard or API port forward bind address. @@ -2539,7 +2539,7 @@ Set them before running `$$nemoclaw onboard`. | `NEMOCLAW_SANDBOX_GPU` | `auto`, `1`, or `0` | Controls sandbox GPU passthrough during onboarding. `auto` enables GPU passthrough when an NVIDIA GPU is detected, `1` requires GPU passthrough, and `0` forces CPU-only sandbox creation. | | `NEMOCLAW_SANDBOX_GPU_DEVICE` | OpenShell GPU device selector | Selects the GPU device passed with `openshell sandbox create --gpu-device`. Requires explicit sandbox GPU enablement with `NEMOCLAW_SANDBOX_GPU=1` (or `--sandbox-gpu` for CLI-driven onboarding); otherwise onboarding rejects the selector instead of treating it as an implicit opt-in. | | `NEMOCLAW_DOCKER_GPU_PATCH` | unset, `auto`, `1`, or `0` | Selects Linux Docker-driver GPU routing. Unset or `auto` uses native OpenShell GPU injection on ordinary native Linux and the compatibility patch on Docker Desktop WSL and Jetson/Tegra. `1` forces the compatibility patch. `0` selects native injection on ordinary native Linux and Jetson/Tegra, but Docker Desktop WSL ignores it. On Jetson/Tegra, use `0` only for troubleshooting because it bypasses the device-group propagation needed for CUDA. | -| `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH` | `1` to enable; disabled by default | Explicitly opts into the Linux gateway compatibility container for an older host ABI or a diagnostic run. This mode uses host networking and mounts the Docker socket read-only, but the socket still exposes the privileged Docker API. Use it only on a trusted local host; prefer OpenShell 0.0.71's directly supported glibc 2.28+ path. See the [OpenShell 0.0.71 gateway auth review](../security/openshell-0.0.71-gateway-auth-review#source-of-truth-boundaries). | +| `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH` | `1` to enable; disabled by default | This setting explicitly opts into the Linux gateway compatibility container for an older host ABI or a diagnostic run; use it only on a trusted local host because it uses host networking and mounts the Docker socket read-only even though the socket still exposes the privileged Docker API; prefer OpenShell 0.0.72's directly supported glibc 2.28+ path; see the [OpenShell 0.0.72 compatibility review](../security/openshell-0.0.72-compatibility-review#source-of-truth-boundaries) for details. | | `NEMOCLAW_OPENSHELL_GATEWAY_BIN` | path | Advanced override for the `openshell-gateway` binary used by the Linux Docker-driver standalone fallback. Defaults to the binary next to `openshell`, then common install paths. | | `NEMOCLAW_OPENSHELL_SANDBOX_BIN` | path | Advanced override for the `openshell-sandbox` binary used by the Linux Docker-driver standalone fallback. Defaults to the binary next to `openshell`, then common install paths. | | `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR` | path | Advanced override for the Linux Docker-driver gateway SQLite state directory and standalone-fallback PID file. Defaults to `~/.local/state/nemoclaw/openshell-docker-gateway`. | diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 0ebb0006ca4..d4062cf7f83 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -141,18 +141,18 @@ openshell policy update --add-endpoint api.example.com:443:read-o ``` To replace the live policy with a complete raw policy file, start from the live policy and use `openshell policy set`. -Requires OpenShell 0.0.44+ for `policy get --full` and `policy set --wait` syntax. +Requires OpenShell 0.0.72+ for the round-trippable `policy get --base` and `policy set --wait` syntax. ```bash # shellcheck shell=bash # Source-of-truth review: -# invalidState: OpenShell 0.0.44 policy get --full emits metadata before the --- YAML header. +# invalidState: OpenShell 0.0.72 policy get --base emits metadata before the --- YAML header. # sourceBoundary: OpenShell CLI output is owned by the separate OpenShell project. # whyNotSourceFix: NemoClaw pins OpenShell but cannot change that upstream formatter here. # regressionTest: test/policy-roundtrip-docs.test.ts validates this shared docs pattern. # removalCondition: remove this pipeline after pinned OpenShell emits clean raw YAML. tmp_policy=$(mktemp) -openshell policy get --full \ +openshell policy get --base \ | awk 'found { print } /^---$/ { found = 1 } END { if (!found) exit 1 }' \ > "$tmp_policy" \ && grep -q '^version:' "$tmp_policy" \ diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 1b350d09982..a09644573bc 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -268,15 +268,20 @@ Remote/headless hosts should keep the OpenShell gateway on loopback and bind the NEMOCLAW_DASHBOARD_BIND=0.0.0.0 NEMOCLAW_GATEWAY_PORT=8990 $$nemoclaw onboard ``` -Docker-driver gateways on OpenShell 0.0.71 reject `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` while gateway JWT auth is active. +Docker-driver gateways on OpenShell 0.0.72 reject `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` while gateway JWT auth is active. Use `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` only on supported gateway modes and only when other hosts on the network should be able to reach the gateway. ### Older-glibc gateway compatibility container -OpenShell 0.0.71 directly supports Linux hosts with glibc 2.28 or newer. On an older trusted host, `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1` explicitly opts into NemoClaw's compatibility container. Leave it unset on supported hosts. +OpenShell 0.0.72 directly supports Linux hosts with glibc 2.28 or newer. +On an older trusted host, `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1` explicitly opts into NemoClaw's compatibility container. +Leave it unset on supported hosts. -The compatibility container uses host networking and mounts the host Docker socket read-only. A read-only socket mount still permits privileged Docker API operations and can control the host, so do not enable this mode on an untrusted or shared host. The gateway remains loopback-bound, and startup fails closed unless the configured Unix socket answers as a Docker daemon. See the [OpenShell 0.0.71 gateway auth review](../security/openshell-0.0.71-gateway-auth-review#source-of-truth-boundaries) for the accepted boundary and removal conditions. +The compatibility container uses host networking and mounts the host Docker socket read-only. +A read-only socket mount still permits privileged Docker API operations and can control the host, so do not enable this mode on an untrusted or shared host. +The gateway remains loopback-bound, and startup fails closed unless the configured Unix socket answers as a Docker daemon. +See the [OpenShell 0.0.72 compatibility review](../security/openshell-0.0.72-compatibility-review#source-of-truth-boundaries) for the accepted boundary and removal conditions. Refer to [Environment Variables](commands#environment-variables) for the full list of port overrides. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 4f142214c2d..a72012e4f86 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -511,7 +511,7 @@ NemoClaw binds the OpenShell gateway to loopback by default. |---|---| | Default | `NEMOCLAW_GATEWAY_BIND_ADDRESS=127.0.0.1`. | | What you can change | Keep Docker-driver gateways on loopback. Set `NEMOCLAW_DASHBOARD_BIND=0.0.0.0` for remote dashboard/API access. | -| Risk if relaxed | Other hosts on the network may be able to reach the OpenShell gateway. Docker-driver gateways on OpenShell 0.0.71 reject wildcard gateway binds while gateway JWT auth is active. | +| Risk if relaxed | Other hosts on the network may be able to reach the OpenShell gateway; Docker-driver gateways on OpenShell 0.0.72 reject wildcard gateway binds while gateway JWT auth is active. | | Recommendation | Keep the gateway loopback default and expose only the dashboard forward when remote access is needed. | ### Gateway Compatibility Container @@ -523,9 +523,9 @@ On Linux hosts whose glibc is older than the OpenShell gateway binary requires, | Default | NemoClaw does not auto-enable the compatibility container on ABI mismatch. If `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1` is set, the container keeps the main gateway listener on `127.0.0.1`, uses host networking so OpenShell computes the same Docker bridge callback addresses as a host-side gateway, mounts the Docker socket read-only, drops Linux capabilities, sets `no-new-privileges`, and publishes no extra Docker ports. | | What you can change | Opt in with `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1`, keep the path disabled with `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=0`, or run on a host/OpenShell build combination where the gateway binary launches directly. | | Risk if relaxed | The Docker socket remains a privileged host API even when bind-mounted read-only. Treat this mode as equivalent to trusting the host user that can drive Docker, and do not enable it on untrusted shared hosts. | -| Recommendation | OpenShell 0.0.71 supports glibc 2.28 or newer. Prefer a directly supported host and use the compatibility container only as an explicit local bridge on an older trusted host. | +| Recommendation | Prefer a host with glibc 2.28 or newer, which OpenShell 0.0.72 supports directly, and use the compatibility container only as an explicit local bridge on an older trusted host. | -See [OpenShell 0.0.71 Gateway Auth Review](./openshell-0.0.71-gateway-auth-review) for source-of-truth boundaries, acceptance mapping, and contract coverage. +See [OpenShell 0.0.72 Compatibility Review](./openshell-0.0.72-compatibility-review) for source-of-truth boundaries and contract coverage. ### Insecure Auth Derivation diff --git a/docs/security/openshell-0.0.72-compatibility-review.mdx b/docs/security/openshell-0.0.72-compatibility-review.mdx new file mode 100644 index 00000000000..16225d38263 --- /dev/null +++ b/docs/security/openshell-0.0.72-compatibility-review.mdx @@ -0,0 +1,91 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "OpenShell 0.0.72 Compatibility Review" +sidebar-title: "OpenShell 0.0.72 Review" +description: "Review the OpenShell 0.0.72 release identity, gateway authentication boundary, policy round-trip behavior, and MCP and JSON-RPC compatibility." +description-agent: "Documents NemoClaw's OpenShell 0.0.72 compatibility boundary, including gateway authentication, provider-composed policy handling, and MCP and JSON-RPC enforcement. Use when validating the OpenShell 0.0.72 dependency pin, reviewing `policy get --base` behavior, or assessing the gateway and network-policy security contract." +keywords: ["openshell 0.0.72 compatibility", "nemoclaw policy round trip", "mcp json-rpc policy", "openshell gateway authentication"] +content: + type: "reference" +--- + +This review covers NemoClaw's stable OpenShell `0.0.72` pin, Docker-driver gateway authentication, policy mutation, and MCP and JSON-RPC policy compatibility. +The review was completed on June 29, 2026. + +## Release Identity + +- The stable tag is `NVIDIA/OpenShell@v0.0.72` at commit `8cb16de9eae4c44d7d31e1493747d8c10abb5963`. +- The upstream [v0.0.72 release workflow](https://github.com/NVIDIA/OpenShell/actions/runs/28382086068) completed all 54 jobs at that commit, including the MCP conformance lane, package smoke tests, release publication, and GHCR tags. +- NemoClaw pins the eight consumed CLI, gateway, and sandbox assets to the digests published by the [GitHub release API](https://api.github.com/repos/NVIDIA/OpenShell/releases/tags/v0.0.72). +- The stable Docker-driver default pins the multi-architecture supervisor manifest as `ghcr.io/nvidia/openshell/supervisor@sha256:80ed9cda5bf672fefdb9dcd4604b40a8b09c0891b6eb9d03e10227c7e3dfb49d`. Explicit operator overrides and the opt-in development channel remain separate trust decisions. + +## Source-of-Truth Boundaries + +The generated gateway authentication contract remains unchanged from the [OpenShell 0.0.71 gateway authentication review](./openshell-0.0.71-gateway-auth-review). +The `v0.0.71...v0.0.72` source comparison does not change the gateway config loader, local TLS tables, mTLS user authentication, gateway JWT issuer, or `SandboxJwtAuthenticator` contract used by NemoClaw. +The live `openshell-gateway-auth-source-contract.test.ts` scenario revalidates that NemoClaw keeps the main OpenShell listener on `127.0.0.1`, rejects unauthenticated Docker-origin calls, accepts a correctly scoped sandbox JWT over guest mTLS, rejects cross-sandbox tokens, and scrubs `OPENSHELL_DISABLE_GATEWAY_AUTH=true`. +The inherited contract also continues to reject `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0`. +User principals remain blocked from sandbox-only methods. + +The compatibility container remains an explicit trusted-host fallback behind `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1`. +It uses host networking and read-only Docker socket access, so directly supported glibc 2.28 or newer hosts remain preferred. +Wildcard gateway binds remain rejected while gateway JWT authentication is active. +Review this fallback at every stable OpenShell bump and remove it in the same NemoClaw release that raises every supported Linux host to OpenShell's native glibc floor and passes the exact-head gateway-authentication and gateway-upgrade matrix without the flag. + +### Compatibility Container Opt-In + +- `invalidState`: A host below OpenShell's native glibc floor silently receives a privileged compatibility path, or the path is treated as equivalent to native execution even though read-only Docker socket access still exposes privileged Docker APIs. +- `sourceBoundary`: OpenShell owns its native glibc floor; NemoClaw owns the explicit `NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1` opt-in, host-networking configuration, read-only socket mount, and gateway authentication controls. +- `whyNotSourceFix`: NemoClaw cannot make an upstream binary support an older host libc, so supported legacy hosts require an explicit, audited container boundary until the host floor is raised. +- `regressionTest`: `test/install-openshell-version-check.test.ts` proves the flag gates the fallback, while `src/lib/onboard/docker-driver-gateway-compat-container.test.ts` covers container launch, the trust boundary, and the glibc decision. +- `removalCondition`: Remove the fallback when every supported Linux host meets OpenShell's native glibc 2.28-or-newer floor and the exact-head gateway-authentication and gateway-upgrade matrix passes without the flag. + +The release source boundary is the immutable upstream tag, its GitHub release asset digests, and the GHCR manifest digest produced by the linked release workflow. +A mutable tag, a digest copied from another release, or a checksum file that disagrees with NemoClaw's table is an invalid state. +NemoClaw cannot make an upstream release mutable source trustworthy after publication, so the installer independently pins every consumed archive and the stable runtime uses the immutable supervisor manifest. +`install-openshell-version-check.test.ts` compares all eight archive mappings with the checked-in installer table, and `docker-driver-gateway-runtime.test.ts` locks the stable supervisor default while preserving an explicit operator override. +These version-specific pins are removed only when NemoClaw drops `0.0.72` support or replaces them with independently verified artifacts for a newly supported release. + +### Dev Channel Opt-In + +- `invalidState`: A mutable development artifact is installed without SHA-256 verification or an explicit operator risk acknowledgment. +- `sourceBoundary`: NVIDIA/OpenShell owns the mutable `dev` tag; NemoClaw owns the opt-in that permits consuming it for pre-release compatibility tests. +- `whyNotSourceFix`: NemoClaw cannot make an upstream development tag immutable, so it must fail closed unless the operator explicitly accepts that unverified install. +- `regressionTest`: `test/install-openshell-version-check.test.ts` proves the development channel fails without `NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL=1` and succeeds with it. +- `removalCondition`: Remove the opt-in when NemoClaw no longer tests unreleased OpenShell builds or the development channel publishes artifacts through an independently verified immutable pipeline. + +The development channel is compatibility evidence only. Use it in trusted test environments, never as the stable shipping configuration. + +## Round-Trippable Policy Boundary + +OpenShell `0.0.72` reserves the `_provider_*` network-policy namespace for provider composition. +`openshell policy get --full` returns the effective policy including those derived entries, while `policy set` rejects user-authored reserved keys. +The invalid state occurs when a NemoClaw read-modify-write path feeds provider-composed `_provider_*` entries back into `openshell policy set`. + +Every NemoClaw policy read-modify-write path, including preset merges and blueprint additions, and every Shields snapshot-for-restore path therefore starts from: + +```bash +openshell policy get --base +``` + +Read-only status and diagnostic views continue to use `--full`. +Regression coverage verifies that mutation commands select `--base`, provider-composed entries never reach `policy set`, and existing MCP policy fields survive a preset or blueprint merge. + +## MCP and JSON-RPC Policy Support + +OpenShell `0.0.72` adds `protocol: mcp` for MCP Streamable HTTP and `protocol: json-rpc` for generic JSON-RPC-over-HTTP enforcement. +MCP rules can match methods and `tools/call` tool names, support allow and deny rules, and fail closed for malformed or ambiguous request frames. +The upstream MCP conformance lane passed `initialize`, `tools_call`, and `elicitation-sep1034-client-defaults` with no expected failures. + +This dependency PR preserves the new MCP and JSON-RPC YAML fields when NemoClaw merges existing policies. +It does not widen NemoClaw's strict blueprint-addition schema to author new MCP endpoints because that is a separate product and API change. +OpenShell enforcement covers sandbox-to-server Streamable HTTP requests, not stdio MCP or generic inbound traffic. + +## Local Contract Coverage + +- Installer and runner tests pin all eight published release digests. +- The sticky-version guard replaces a too-new `0.0.73` install with `0.0.72`. +- Policy tests cover `--base` command construction and MCP and JSON-RPC field preservation. +- Blueprint tests prove the merged policy excludes reserved provider entries. +- The live gateway authentication and gateway-upgrade scenarios run against `0.0.72`. diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index a0f9616ae4e..05851b7253e 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -2,8 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 version: "0.1.0" -min_openshell_version: "0.0.71" -max_openshell_version: "0.0.71" +min_openshell_version: "0.0.72" +max_openshell_version: "0.0.72" min_openclaw_version: "2026.3.11" # Mirrors the components.sandbox.image manifest digest below. Lets a # downstream consumer (or release tooling) verify the blueprint declares diff --git a/nemoclaw/package-lock.json b/nemoclaw/package-lock.json index 4763ee86c05..aafa3e101d8 100644 --- a/nemoclaw/package-lock.json +++ b/nemoclaw/package-lock.json @@ -12,7 +12,7 @@ "execa": "^9.6.1", "json5": "^2.2.3", "tar": "^7.0.0", - "yaml": "^2.4.0" + "yaml": "2.8.3" }, "devDependencies": { "@biomejs/biome": "^2.4.14", diff --git a/nemoclaw/package.json b/nemoclaw/package.json index 0266ad67c0e..c2298468a87 100644 --- a/nemoclaw/package.json +++ b/nemoclaw/package.json @@ -33,7 +33,7 @@ "execa": "^9.6.1", "json5": "^2.2.3", "tar": "^7.0.0", - "yaml": "^2.4.0" + "yaml": "2.8.3" }, "devDependencies": { "@biomejs/biome": "^2.4.14", diff --git a/nemoclaw/src/blueprint/runner-openshell-072-policy.test.ts b/nemoclaw/src/blueprint/runner-openshell-072-policy.test.ts new file mode 100644 index 00000000000..e8b4c9f8b0d --- /dev/null +++ b/nemoclaw/src/blueprint/runner-openshell-072-policy.test.ts @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type fs from "node:fs"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; + +type FsEntry = { type: "file" | "dir"; content?: string }; + +const store = new Map(); +const mockExeca = vi.fn(); + +vi.mock("node:crypto", () => ({ + randomUUID: () => "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", +})); + +vi.mock("node:os", () => ({ + homedir: () => "/fakehome", +})); + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + mkdirSync: vi.fn((path: string) => { + store.set(path, { type: "dir" }); + }), + writeFileSync: vi.fn((path: string, data: string) => { + store.set(path, { type: "file", content: String(data) }); + }), + }; +}); + +vi.mock("execa", () => ({ + execa: (...args: unknown[]) => mockExeca(...args), +})); + +vi.mock("./ssrf.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + validateEndpointUrl: vi.fn(async (url: string) => ({ + url, + pinnedUrl: url, + protocol: url.startsWith("http:") ? "http:" : "https:", + hostname: new URL(url).hostname, + dnsResolved: false, + })), + }; +}); + +const { actionApply } = await import("./runner.js"); + +const BASE_POLICY = `version: 1 +future_policy: + opaque_setting: + keep: true +filesystem_policy: + default: deny + roots: [/sandbox] +metadata: + future_schema: opaque + preserve: true +network_policies: + existing_mcp: + endpoints: + - host: mcp.example.com + port: 443 + path: /mcp + protocol: mcp + enforcement: enforce + mcp: + allow_all_known_mcp_methods: true + max_body_bytes: 131072 + strict_tool_names: true + rules: + - allow: + method: tools/call + tool: + any: [search_web, list_tools] + - allow: + method: resources/read + deny_rules: + - method: tools/call + tool: + any: [send_email, delete_resource] + existing_json_rpc: + endpoints: + - host: rpc.example.com + port: 443 + path: /rpc + protocol: json-rpc + enforcement: enforce + json_rpc: { max_body_bytes: 131072 } + rules: + - allow: + method: { any: [reports.search, reports.get] } +`; + +const FULL_POLICY = `${BASE_POLICY} _provider_nvidia-inference: {} +`; + +function policyOutput(policy: string): string { + return ["Version: 1", "Hash: sha256:test", "---", policy].join("\n"); +} + +function policySetCalls(): unknown[][] { + return mockExeca.mock.calls.filter( + (call) => Array.isArray(call[1]) && call[1][0] === "policy" && call[1][1] === "set", + ); +} + +function mergedPolicy(): Record { + const key = [...store.keys()].find((candidate) => candidate.endsWith("/merged-policy.yaml")); + expect(key).toBeDefined(); + return YAML.parse(store.get(key ?? "")?.content ?? ""); +} + +function blueprint(): Parameters[1] { + return { + version: "1.0", + components: { + inference: { + profiles: { + default: { + provider_type: "openai", + provider_name: "my-provider", + endpoint: "https://api.example.com/v1", + model: "gpt-4", + credential_env: "MY_API_KEY", + }, + }, + }, + sandbox: { + image: "openclaw", + name: "test-sandbox", + forward_ports: [18789], + }, + policy: { + additions: { + nim_service: { + name: "nim_service", + endpoints: [{ host: "integrate.api.nvidia.com", port: 443, access: "full" }], + }, + }, + }, + }, + }; +} + +describe("OpenShell 0.0.72 blueprint policy round-trip", () => { + beforeEach(() => { + store.clear(); + mockExeca.mockReset(); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const policyByCommand = new Map([ + ["policy get --base test-sandbox", policyOutput(BASE_POLICY)], + ["policy get --full test-sandbox", policyOutput(FULL_POLICY)], + ]); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => ({ + exitCode: 0, + stdout: policyByCommand.get(args.slice(0, 4).join(" ")) ?? "", + stderr: "", + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("preserves MCP, JSON-RPC, and unknown mapping sections without provider entries", async () => { + await actionApply("default", blueprint()); + + expect(mockExeca).toHaveBeenCalledWith( + "openshell", + ["policy", "get", "--base", "test-sandbox"], + expect.objectContaining({ reject: false }), + ); + expect(mockExeca).not.toHaveBeenCalledWith( + "openshell", + ["policy", "get", "--full", "test-sandbox"], + expect.anything(), + ); + + const merged = mergedPolicy() as { + future_policy: { opaque_setting: { keep: boolean } }; + filesystem_policy: { default: string; roots: string[] }; + metadata: { future_schema: string; preserve: boolean }; + network_policies: Record; + }; + expect(merged.future_policy).toEqual({ opaque_setting: { keep: true } }); + expect(merged.filesystem_policy).toEqual({ default: "deny", roots: ["/sandbox"] }); + expect(merged.metadata).toEqual({ future_schema: "opaque", preserve: true }); + expect(merged.network_policies).toEqual({ + ...YAML.parse(BASE_POLICY).network_policies, + nim_service: expect.any(Object), + }); + expect(merged.network_policies).not.toHaveProperty("_provider_nvidia-inference"); + }); + + it.each([ + ["scalar", "future_mode", "future_mode: strict\n"], + ["sequence", "future_features", "future_features: [audit, attribution]\n"], + ])("fails closed for an unknown top-level %s", async (_shape, key, fragment) => { + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => ({ + exitCode: 0, + stdout: + args.slice(0, 4).join(" ") === "policy get --base test-sandbox" + ? policyOutput(`${fragment}${BASE_POLICY}`) + : "", + stderr: "", + })); + + await expect(actionApply("default", blueprint())).rejects.toThrow( + `Current policy top-level field "${key}" must be a YAML mapping`, + ); + expect(policySetCalls()).toEqual([]); + }); + + it("fails closed when policy get --base fails", async () => { + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.slice(0, 4).join(" ") === "policy get --base test-sandbox" + ? { exitCode: 1, stdout: "", stderr: "gateway unavailable" } + : { exitCode: 0, stdout: "", stderr: "" }, + ); + + await expect(actionApply("default", blueprint())).rejects.toThrow( + /Failed to read current policy.*gateway unavailable/, + ); + expect(policySetCalls()).toEqual([]); + }); + + it("fails closed when policy get --base returns metadata without a policy document", async () => { + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => ({ + exitCode: 0, + stdout: + args.slice(0, 4).join(" ") === "policy get --base test-sandbox" + ? "Version: 1\nHash: sha256:test\n" + : "", + stderr: "", + })); + + await expect(actionApply("default", blueprint())).rejects.toThrow( + /does not contain a policy YAML document/, + ); + expect(policySetCalls()).toEqual([]); + }); + + it("filters a malformed provider-composed entry returned by --base", async () => { + const malformedBase = YAML.parse(BASE_POLICY); + malformedBase.network_policies["_provider_unexpected"] = { + endpoints: [{ host: "provider.invalid", port: 443, access: "full" }], + }; + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => ({ + exitCode: 0, + stdout: + args.slice(0, 4).join(" ") === "policy get --base test-sandbox" + ? policyOutput(YAML.stringify(malformedBase)) + : "", + stderr: "", + })); + + await actionApply("default", blueprint()); + const merged = mergedPolicy() as { network_policies: Record }; + expect(merged.network_policies).not.toHaveProperty("_provider_unexpected"); + expect(merged.network_policies).toHaveProperty("existing_mcp"); + expect(merged.network_policies).toHaveProperty("existing_json_rpc"); + }); + + it("filters reserved provider entries from the final blueprint mutation payload", async () => { + const blueprintWithReservedAddition = blueprint(); + blueprintWithReservedAddition.components!.policy!.additions!._provider_injected = { + name: "must-not-submit", + endpoints: [{ host: "provider.invalid", port: 443, access: "full" }], + }; + + await actionApply("default", blueprintWithReservedAddition); + + const merged = mergedPolicy() as { network_policies: Record }; + expect(merged.network_policies).not.toHaveProperty("_provider_injected"); + expect(merged.network_policies).toHaveProperty("nim_service"); + }); + + it("fails closed for a legacy network_policies array instead of dropping it", async () => { + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => ({ + exitCode: 0, + stdout: + args.slice(0, 4).join(" ") === "policy get --base test-sandbox" + ? policyOutput("version: 1\nnetwork_policies:\n - name: legacy\n") + : "", + stderr: "", + })); + + await expect(actionApply("default", blueprint())).rejects.toThrow( + /network_policies must be a YAML mapping/, + ); + expect(policySetCalls()).toEqual([]); + }); +}); diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index 4dcbf3ee574..22f533eea9f 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -193,7 +193,7 @@ function mockCurrentPolicy(stdout: string): void { if ( args[0] === "policy" && args[1] === "get" && - args[2] === "--full" && + args[2] === "--base" && args[3] === "test-sandbox" ) { return { exitCode: 0, stdout, stderr: "" }; @@ -638,7 +638,7 @@ describe("runner", () => { ); }); - it("applies blueprint policy additions by merging into the live policy", async () => { + it("applies blueprint policy additions by merging into the base policy", async () => { const bp = minimalBlueprint({ components: { inference: { @@ -673,12 +673,11 @@ describe("runner", () => { }, }, }); - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => { if ( args[0] === "policy" && args[1] === "get" && - args[2] === "--full" && + args[2] === "--base" && args[3] === "test-sandbox" ) { return { @@ -774,7 +773,7 @@ describe("runner", () => { expect(policySetCalls).toEqual([]); }); - it("fails closed when policy get --full does not include a policy document", async () => { + it("fails closed when policy get --base does not include a policy document", async () => { const bp = blueprintWithPolicyAdditions({ nim_service: { name: "nim_service", @@ -792,7 +791,7 @@ describe("runner", () => { expect(policySetCalls).toEqual([]); }); - it("can merge policy additions into an empty policy document", async () => { + it("fails closed when policy get --base returns metadata without a policy document", async () => { const bp = blueprintWithPolicyAdditions({ nim_service: { name: "nim_service", @@ -801,20 +800,13 @@ describe("runner", () => { }); mockCurrentPolicy(["Version: 1", "Hash: sha256:test", "---"].join("\n")); - await actionApply("default", bp); - - const mergedPolicyKey = [...store.keys()].find( - (k) => k.endsWith("/merged-policy.yaml") || k.endsWith("\\merged-policy.yaml"), + await expect(actionApply("default", bp)).rejects.toThrow( + /does not contain a policy YAML document/i, ); - if (!mergedPolicyKey) throw new Error("merged policy file not written"); - const mergedEntry = store.get(mergedPolicyKey); - if (!mergedEntry?.content) throw new Error("merged policy file is empty"); - const merged = YAML.parse(mergedEntry.content) as { - version?: number; - network_policies?: Record; - }; - expect(merged.version).toBe(1); - expect(merged.network_policies).toHaveProperty("nim_service"); + const policySetCalls = mockExeca.mock.calls.filter( + (call) => Array.isArray(call[1]) && call[1][0] === "policy" && call[1][1] === "set", + ); + expect(policySetCalls).toEqual([]); }); it("skips policy commands when policy additions are empty", async () => { diff --git a/nemoclaw/src/blueprint/runner.ts b/nemoclaw/src/blueprint/runner.ts index 8f64b113f15..932c1da0aa6 100644 --- a/nemoclaw/src/blueprint/runner.ts +++ b/nemoclaw/src/blueprint/runner.ts @@ -13,16 +13,26 @@ */ import { randomUUID } from "node:crypto"; -import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, sep } from "node:path"; import { execa } from "execa"; import YAML from "yaml"; -import { safeEndpointUrlForDownstream, validateEndpointUrl } from "./ssrf.js"; -import { buildSubprocessEnv } from "../lib/subprocess-env.js"; import { DASHBOARD_PORT } from "../lib/ports.js"; +import { buildSubprocessEnv } from "../lib/subprocess-env.js"; +import * as importedOpenShellPolicyBoundary from "../shared/openshell-policy-boundary.cjs"; +import { safeEndpointUrlForDownstream, validateEndpointUrl } from "./ssrf.js"; + +// The compiled plugin exposes named CommonJS exports. Source-mode tsx maps the +// .cjs specifier back to .cts and exposes that same module as its default. +const sourceOrGeneratedOpenShellPolicyBoundary = + importedOpenShellPolicyBoundary as typeof importedOpenShellPolicyBoundary & { + default?: typeof importedOpenShellPolicyBoundary; + }; +const { parseOpenShellPolicy, withoutProviderComposedPolicies } = + sourceOrGeneratedOpenShellPolicyBoundary.default ?? sourceOrGeneratedOpenShellPolicyBoundary; type Action = "plan" | "apply" | "status" | "rollback"; @@ -324,32 +334,9 @@ interface RouterConfig { const DEFAULT_ROUTER_PORT = 4000; -function parseCurrentPolicy(raw: string): UnknownRecord { - const sepIndex = raw.indexOf("---"); - const yaml = (sepIndex >= 0 ? raw.slice(sepIndex + 3) : raw).trim(); - if (!yaml) return {}; - - let parsed: unknown; - try { - parsed = YAML.parse(yaml); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`Current policy from openshell policy get --full is not valid YAML: ${detail}`); - } - - if (!isObjectLike(parsed)) { - throw new Error("Current policy from openshell policy get --full must be a YAML mapping"); - } - if (sepIndex < 0 && !("version" in parsed) && !("network_policies" in parsed)) { - throw new Error( - "Current policy from openshell policy get --full does not contain a policy YAML document", - ); - } - return parsed; -} - function mergePolicyAdditions(currentPolicyRaw: string, additions: PolicyAdditions): string { - const current = parseCurrentPolicy(currentPolicyRaw); + // sourceOfTruth: nemoclaw/src/shared/openshell-policy-boundary.cts + const current = parseOpenShellPolicy(currentPolicyRaw).policy; if (current.network_policies !== undefined && !isObjectLike(current.network_policies)) { throw new Error("Current policy network_policies must be a YAML mapping"); } @@ -358,15 +345,25 @@ function mergePolicyAdditions(currentPolicyRaw: string, additions: PolicyAdditio : {}; const output: UnknownRecord = {}; + // Stable OpenShell 0.0.72 exposes composable top-level policy sections as + // mappings. Preserve unknown mapping sections for forward compatibility, but + // fail closed on a scalar or sequence until its mutation semantics are + // reviewed for the next supported OpenShell contract. for (const [key, value] of Object.entries(current)) { if (key !== "version" && key !== "network_policies") { + if (!isObjectLike(value)) { + throw new Error(`Current policy top-level field "${key}" must be a YAML mapping`); + } output[key] = value; } } output.version = typeof current.version === "number" && Number.isFinite(current.version) ? current.version : 1; - output.network_policies = { ...existingNetworkPolicies, ...additions }; + output.network_policies = withoutProviderComposedPolicies({ + ...existingNetworkPolicies, + ...additions, + }); return YAML.stringify(output); } @@ -788,7 +785,7 @@ export async function actionApply( if (Object.keys(policyAdditions).length > 0) { progress(78, "Applying policy additions"); - const currentPolicy = await runCmd(["openshell", "policy", "get", "--full", sandboxName], { + const currentPolicy = await runCmd(["openshell", "policy", "get", "--base", sandboxName], { reject: false, }); if (currentPolicy.exitCode !== 0) { diff --git a/nemoclaw/src/shared/openshell-policy-boundary.cts b/nemoclaw/src/shared/openshell-policy-boundary.cts new file mode 100644 index 00000000000..475c3e2d2af --- /dev/null +++ b/nemoclaw/src/shared/openshell-policy-boundary.cts @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import YAML from "yaml"; + +export type OpenShellPolicyMapping = Record; + +export interface ParsedOpenShellPolicy { + readonly yamlBody: string; + readonly policy: OpenShellPolicyMapping; +} + +const MISSING_POLICY_DOCUMENT = + "Current policy from openshell policy get --base does not contain a policy YAML document"; + +function isMapping(value: unknown): value is OpenShellPolicyMapping { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseYaml(source: string, invalidMessage: string): unknown { + try { + return YAML.parse(source); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${invalidMessage}: ${detail}`); + } +} + +// sourceOfTruth: This is the only implementation of the OpenShell +// metadata/YAML parse boundary and provider-composed policy filter. +// consumers: The root CommonJS CLI consumes the generated .cjs through its +// typed wrapper; the ESM plugin runner imports that same generated .cjs. +// invalidState: `policy get --base` can return metadata-only, diagnostic, or +// malformed YAML output that must never be mistaken for an empty policy. +// sourceBoundary: OpenShell owns command output; this parser owns the trusted +// YAML mapping admitted to every NemoClaw policy mutation. +// whyNotSourceFix: NemoClaw must remain safe with the supported OpenShell CLI +// even when a gateway or older command path returns degraded output. +// regressionTest: package-contract parser parity plus root and plugin policy +// tests cover the fail-soft and strict consumers. +// removalCondition: remove only when no NemoClaw consumer parses OpenShell +// policy command output or OpenShell provides an equivalent typed API. +export function parseOpenShellPolicy(raw: string): ParsedOpenShellPolicy { + const separator = /(?:^|\r?\n)---[ \t]*(?:\r?\n|$)/.exec(raw); + const yamlBody = (separator ? raw.slice(separator.index + separator[0].length) : raw).trim(); + if (!yamlBody) { + throw new Error(MISSING_POLICY_DOCUMENT); + } + + const parsed = parseYaml( + yamlBody, + "Current policy from openshell policy get --base is not valid YAML", + ); + if (!isMapping(parsed)) { + throw new Error("Current policy from openshell policy get --base must be a YAML mapping"); + } + if ( + parsed.version !== undefined && + (typeof parsed.version !== "number" || + !Number.isInteger(parsed.version) || + parsed.version < 1) + ) { + throw new Error( + "Current policy from openshell policy get --base version must be a positive integer", + ); + } + if (parsed.network_policies !== undefined && !isMapping(parsed.network_policies)) { + throw new Error("Current policy network_policies must be a YAML mapping"); + } + + // Unmarked output is accepted only when it has a positive policy-root + // identity. OpenShell diagnostic mappings are otherwise indistinguishable + // from policy YAML and must never reach a read-modify-write caller. A marked + // document may contain only future top-level fields because the marker is the + // policy identity; versionless network_policies remains compatible. + if (!separator && !("version" in parsed) && !("network_policies" in parsed)) { + throw new Error(MISSING_POLICY_DOCUMENT); + } + + return { yamlBody, policy: parsed }; +} + +// invalidState: OpenShell `policy get --base` unexpectedly includes a +// provider-composed `_provider_*` entry that `policy set` must never receive. +// sourceBoundary: OpenShell owns base-policy composition; NemoClaw owns every +// read-modify-write payload it submits. +// whyNotSourceFix: the upstream formatter cannot be fixed from this repository, +// so filter defensively until the supported contract guarantees their absence. +// regressionTest: the root policy round-trip and plugin runner policy tests. +// removalCondition: OpenShell's supported base-policy contract guarantees that +// provider-composed entries are absent from every mutation read. +// tracking: revalidate this guard at every stable OpenShell pin after 0.0.72. +export function withoutProviderComposedPolicies(policies: Record): Record { + return Object.fromEntries( + Object.entries(policies).filter(([name]) => !name.startsWith("_provider_")), + ); +} + +export function stripProviderComposedPolicies(policy: string): string { + const parsed = parseYaml( + policy, + "Cannot filter provider-composed policy entries from invalid YAML", + ); + if (!isMapping(parsed) || !isMapping(parsed.network_policies)) return policy; + + const filtered = withoutProviderComposedPolicies(parsed.network_policies); + if (Object.keys(filtered).length === Object.keys(parsed.network_policies).length) return policy; + return YAML.stringify({ ...parsed, network_policies: filtered }); +} diff --git a/nemoclaw/src/shared/openshell-policy-boundary.test.ts b/nemoclaw/src/shared/openshell-policy-boundary.test.ts new file mode 100644 index 00000000000..c5021300051 --- /dev/null +++ b/nemoclaw/src/shared/openshell-policy-boundary.test.ts @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +import { + parseOpenShellPolicy, + stripProviderComposedPolicies, + withoutProviderComposedPolicies, +} from "./openshell-policy-boundary.cjs"; + +type PolicyDecision = "accepted" | "rejected"; + +function parseDecision(raw: string): PolicyDecision { + try { + parseOpenShellPolicy(raw); + return "accepted"; + } catch { + return "rejected"; + } +} + +const POLICY_CASES = [ + { + name: "valid marked policy", + raw: "Version: 1\n---\nversion: 1\nnetwork_policies:\n safe: {}", + decision: "accepted", + }, + { + name: "unmarked mapping without a policy root", + raw: "future_policy:\n keep: true", + decision: "rejected", + }, + { + name: "versionless network policy", + raw: "network_policies:\n safe: {}", + decision: "accepted", + }, + { name: "missing document", raw: "", decision: "rejected" }, + { + name: "diagnostic output", + raw: "error: gateway unavailable", + decision: "rejected", + }, + { + name: "diagnostic message mapping", + raw: "message: gateway unavailable\ndetails: connection refused", + decision: "rejected", + }, + { + name: "arbitrary lowercase diagnostic mapping", + raw: "reason: gateway unavailable\nretryable: true", + decision: "rejected", + }, + { + name: "malformed YAML", + raw: "version: [unterminated", + decision: "rejected", + }, + { name: "scalar document", raw: "---\nscalar", decision: "rejected" }, + { + name: "sequence document", + raw: "---\n- item", + decision: "rejected", + }, + { + name: "null network policies", + raw: "version: 1\nnetwork_policies: null", + decision: "rejected", + }, + { + name: "string version", + raw: 'version: "1"\nnetwork_policies: {}', + decision: "rejected", + }, + { + name: "fractional version", + raw: "version: 1.5\nnetwork_policies: {}", + decision: "rejected", + }, +] as const; + +describe("canonical OpenShell policy boundary", () => { + it("parses marked output and versionless network policies", () => { + const body = "version: 1\nnetwork_policies:\n safe: {}"; + expect(parseOpenShellPolicy(`Version: 1\n---\n${body}`)).toEqual({ + yamlBody: body, + policy: YAML.parse(body), + }); + + const versionless = "network_policies:\n safe: {}"; + expect(parseOpenShellPolicy(versionless).yamlBody).toBe(versionless); + + const inlineSeparator = 'version: 1\nmetadata:\n marker: "a---b"\nnetwork_policies: {}'; + expect(parseOpenShellPolicy(inlineSeparator).yamlBody).toBe(inlineSeparator); + + const markedFuturePolicy = "Version: 1\n---\nfuture_policy:\n keep: true"; + expect(parseOpenShellPolicy(markedFuturePolicy).policy).toEqual({ + future_policy: { keep: true }, + }); + }); + + it("rejects missing, diagnostic, malformed, scalar, and unmarked policy output", () => { + for (const raw of ["", "Version: 1\n---", "error: gateway unavailable"]) { + expect(() => parseOpenShellPolicy(raw)).toThrow(/does not contain a policy/); + } + expect(() => parseOpenShellPolicy("version: [unterminated")).toThrow(/not valid YAML/); + expect(() => parseOpenShellPolicy("---\nscalar")).toThrow(/must be a YAML mapping/); + for (const raw of [ + "version: 1\nnetwork_policies: invalid", + "version: 1\nnetwork_policies: []", + "version: 1\nnetwork_policies: null", + ]) { + expect(() => parseOpenShellPolicy(raw)).toThrow(/network_policies must be a YAML mapping/); + } + for (const raw of [ + 'version: "1"\nnetwork_policies: {}', + "version: 1.5\nnetwork_policies: {}", + ]) { + expect(() => parseOpenShellPolicy(raw)).toThrow(/version must be a positive integer/); + } + expect(() => parseOpenShellPolicy("FutureKey: value")).toThrow(/does not contain a policy/); + }); + + it.each(POLICY_CASES)("returns $decision for $name", ({ raw, decision }) => { + expect(parseDecision(raw)).toBe(decision); + }); + + it("removes provider-composed policies without mutating other policy fields", () => { + expect( + withoutProviderComposedPolicies({ safe: { allow: true }, _provider_generated: {} }), + ).toEqual({ safe: { allow: true } }); + + const policy = YAML.stringify({ + version: 1, + future_policy: { keep: true }, + network_policies: { safe: {}, _provider_generated: {} }, + }); + expect(YAML.parse(stripProviderComposedPolicies(policy))).toEqual({ + version: 1, + future_policy: { keep: true }, + network_policies: { safe: {} }, + }); + }); + + it("leaves non-composed mappings unchanged and rejects malformed YAML", () => { + for (const policy of ["version: 1", "version: 1\nnetwork_policies:\n safe: {}"]) { + expect(stripProviderComposedPolicies(policy)).toBe(policy); + } + expect(() => stripProviderComposedPolicies("version: [unterminated")).toThrow(/invalid YAML/); + }); +}); diff --git a/nemoclaw/tsconfig.shared.json b/nemoclaw/tsconfig.shared.json new file mode 100644 index 00000000000..655f1686162 --- /dev/null +++ b/nemoclaw/tsconfig.shared.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/shared/openshell-policy-boundary.cts"], + "exclude": ["node_modules", "dist"] +} diff --git a/nemoclaw/vitest.config.ts b/nemoclaw/vitest.config.ts index 2b8650af546..e8a946108f8 100644 --- a/nemoclaw/vitest.config.ts +++ b/nemoclaw/vitest.config.ts @@ -1,10 +1,26 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import path from "node:path"; + import { defineConfig } from "vitest/config"; +const canonicalOpenShellPolicyBoundary = path.resolve( + import.meta.dirname, + "src/shared/openshell-policy-boundary.cts", +); + export default defineConfig({ + oxc: { + include: /\.(?:[cm]?ts|[jt]sx)$/, + }, test: { + alias: [ + { + find: /^.*openshell-policy-boundary\.cjs$/, + replacement: canonicalOpenShellPolicyBoundary, + }, + ], environment: "node", include: ["src/**/*.test.ts"], }, diff --git a/package-lock.json b/package-lock.json index 5341a58a866..df4b45987b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "js-yaml": "^4.1.1", "p-retry": "^4.6.2", "qrcode-terminal": "^0.12.0", - "yaml": "^2.8.3" + "yaml": "2.8.3" }, "bin": { "nemo-deepagents": "bin/nemoclaw.js", diff --git a/package.json b/package.json index 0a0d773d9dd..a85efcba28c 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "format:ts": "cd nemoclaw && npm run lint:fix && npm run format", "check:installer-hash": "bash scripts/check-installer-hash.sh", "typecheck": "tsc -p jsconfig.json", - "build:cli": "tsc -p tsconfig.src.json && node dist/lib/cli/generate-oclif-metadata-manifest.js && if find nemoclaw-blueprint/scripts -name '*.ts' -print -quit | grep -q .; then tsc -p nemoclaw-blueprint/tsconfig.json; fi", + "build:cli": "tsc -p nemoclaw/tsconfig.shared.json && tsc -p tsconfig.src.json && node dist/lib/cli/generate-oclif-metadata-manifest.js && if find nemoclaw-blueprint/scripts -name '*.ts' -print -quit | grep -q .; then tsc -p nemoclaw-blueprint/tsconfig.json; fi", "clean:cli": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "typecheck:cli": "tsc -p tsconfig.cli.json", "validate:configs": "tsx scripts/validate-configs.ts", @@ -72,7 +72,7 @@ "js-yaml": "^4.1.1", "p-retry": "^4.6.2", "qrcode-terminal": "^0.12.0", - "yaml": "^2.8.3" + "yaml": "2.8.3" }, "bundleDependencies": [ "p-retry" diff --git a/scripts/brev-launchable-ci-cpu.sh b/scripts/brev-launchable-ci-cpu.sh index 192b671bc05..178b08ad2de 100755 --- a/scripts/brev-launchable-ci-cpu.sh +++ b/scripts/brev-launchable-ci-cpu.sh @@ -27,7 +27,7 @@ # curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw//scripts/brev-launchable-ci-cpu.sh | bash # # Environment overrides: -# OPENSHELL_VERSION — OpenShell CLI release tag (default: v0.0.71) +# OPENSHELL_VERSION — OpenShell CLI release tag (default: v0.0.72) # NEMOCLAW_REF — NemoClaw git ref to clone (default: main) # NEMOCLAW_CLONE_DIR — Where to clone NemoClaw (default: ~/NemoClaw) # @@ -38,7 +38,7 @@ set -euo pipefail # ── Configuration ──────────────────────────────────────────────────── -OPENSHELL_VERSION="${OPENSHELL_VERSION:-v0.0.71}" +OPENSHELL_VERSION="${OPENSHELL_VERSION:-v0.0.72}" NEMOCLAW_REF="${NEMOCLAW_REF:-main}" TARGET_USER="${SUDO_USER:-$(id -un)}" TARGET_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6)" @@ -127,11 +127,11 @@ openshell_cli_asset_for_arch() { openshell_cli_pinned_sha256() { local release_tag="$1" asset="$2" case "${release_tag}:${asset}" in - v0.0.71:openshell-x86_64-unknown-linux-musl.tar.gz) - printf '%s\n' "b71e3a7fb6973c7c353521f88740885e6e661a199b6355140d45f4f8ab72d716" + v0.0.72:openshell-x86_64-unknown-linux-musl.tar.gz) + printf '%s\n' "37836c3b50383e03249c5e16512c1806e591fba8451408a84fb2f628ddb318c4" ;; - v0.0.71:openshell-aarch64-unknown-linux-musl.tar.gz) - printf '%s\n' "b86b33d9e7c960cd04bc99a9539964f1cb84ae4a9886dd437c0566b64e093390" + v0.0.72:openshell-aarch64-unknown-linux-musl.tar.gz) + printf '%s\n' "a5ff01a3240d73c72ec1700eda6cc6c752a86cf50c5dd1b5bdc459f544d03045" ;; *) return 1 diff --git a/scripts/check-installer-hash.sh b/scripts/check-installer-hash.sh index 362c086c4b4..b1c6ab7d0e1 100755 --- a/scripts/check-installer-hash.sh +++ b/scripts/check-installer-hash.sh @@ -2,24 +2,33 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Verifies that pinned SHA-256 hashes for downloaded installers still match -# the current upstream scripts. +# Verifies that pinned SHA-256 hashes for downloaded OpenShell release assets +# still match the immutable upstream checksum manifests. # -# Checked installers: -# 1. Ollama installer — scripts/install.sh (OLLAMA_INSTALL_SHA256) +# Checked artifacts: +# 1. OpenShell v0.0.72 — scripts/install-openshell.sh release-asset table +# 2. Brev OpenShell CLI — scripts/brev-launchable-ci-cpu.sh release-asset table # # Usage: # scripts/check-installer-hash.sh # exit 0 if current, 1 if stale -# scripts/check-installer-hash.sh --update # rewrite stale hashes in-place +# +# CI can execute this script from a trusted checkout while inspecting a +# separate pull-request tree by setting NEMOCLAW_INSTALLER_HASH_REPO_ROOT. set -euo pipefail -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +if [[ -n "${NEMOCLAW_INSTALLER_HASH_REPO_ROOT:-}" ]]; then + REPO_ROOT="$(cd "$NEMOCLAW_INSTALLER_HASH_REPO_ROOT" && pwd)" +else + REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +fi +CHECKER_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OPENSHELL_RELEASE_VERSION="0.0.72" case "${1:-}" in - "" | --update) ;; + "") ;; *) - echo "Usage: scripts/check-installer-hash.sh [--update]" >&2 + echo "Usage: scripts/check-installer-hash.sh" >&2 exit 2 ;; esac @@ -27,105 +36,154 @@ esac # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -fetch_hash() { - local url="$1" tmpfile - tmpfile=$(mktemp) - trap 'rm -f "$tmpfile"' RETURN - +fetch_file() { + local url="$1" destination="$2" curl --proto '=https' --tlsv1.2 -fsSL \ --connect-timeout 10 --max-time 30 \ --retry 3 --retry-delay 1 --retry-all-errors \ - -o "$tmpfile" "$url" + -o "$destination" "$url" +} +sha256_file() { + local file="$1" if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$tmpfile" | awk '{print $1}' + sha256sum "$file" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$tmpfile" | awk '{print $1}' + shasum -a 256 "$file" | awk '{print $1}' else echo "ERROR: No SHA-256 tool available (sha256sum/shasum)." >&2 return 1 fi } -extract_pinned() { - local file="$1" var_name="$2" - sed -n "s/.*${var_name}=\"\\([a-f0-9]\\{64\\}\\)\".*/\\1/p" "$file" | head -1 -} - -update_pinned() { - local file="$1" old_hash="$2" new_hash="$3" - sed -i.bak "s/${old_hash}/${new_hash}/" "$file" - rm -f "${file}.bak" -} - -# --------------------------------------------------------------------------- -# Registry of pinned hashes: (label, file, variable, upstream URL) -# --------------------------------------------------------------------------- -LABELS=() -FILES=() -VARS=() -URLS=() +# invalidState: CI reports trusted OpenShell pins without comparing every +# consumed archive with the immutable v0.0.72 checksum release assets. +# sourceBoundary: NVIDIA/OpenShell owns the release assets and their published +# digests; NemoClaw owns this independent verification of its local pin table. +# In pull-request CI, this checker and its pin parser execute only from the +# base-trusted checkout or the immutable bootstrap checkout, never from the PR +# head; installer files from the PR head are treated strictly as input data. +# whyNotSourceFix: an upstream release cannot validate which artifacts a +# downstream installer consumes, so this comparison must remain in NemoClaw. +# regressionTest: test/installer-hash-check.test.ts proves download failures and +# altered checksum manifests fail closed; the workflow also runs this live. +# removalCondition: remove this check only when the installer no longer embeds +# release-asset digests or an equivalent independent verifier replaces it. +check_openshell_release_assets() { + local installer="${REPO_ROOT}/scripts/install-openshell.sh" + local brev_installer="${REPO_ROOT}/scripts/brev-launchable-ci-cpu.sh" + local release_base="https://github.com/NVIDIA/OpenShell/releases/download/v${OPENSHELL_RELEASE_VERSION}" + local workspace manifests spec manifest expected actual source asset pinned upstream matches + local pin_records parser_error parser_errors + local count=0 brev_count=0 published_count=0 failures=0 + local -a manifest_specs=( + "openshell-checksums-sha256.txt:0049181983eaf925ef9510382f75348229a9511d02e27196107782e7c3259ae1" + "openshell-gateway-checksums-sha256.txt:3c454dc15154b8c700ec820628559ea8964c6e552d9c5f8af78b6ee19cf34547" + "openshell-sandbox-checksums-sha256.txt:d38507501338576437cf3e554df71fefe927dc0d72758f88e260069527ed9ccc" + ) + workspace=$(mktemp -d) + manifests="${workspace}/published-sha256.txt" + : >"$manifests" + trap 'rm -rf "$workspace"' RETURN + + echo "Checking OpenShell v${OPENSHELL_RELEASE_VERSION} release assets..." + for spec in "${manifest_specs[@]}"; do + manifest="${spec%%:*}" + expected="${spec#*:}" + if ! fetch_file "${release_base}/${manifest}" "${workspace}/${manifest}"; then + echo " STALE: unable to download ${manifest}." + failures=$((failures + 1)) + continue + fi + if ! actual=$(sha256_file "${workspace}/${manifest}"); then + echo " STALE: unable to hash ${manifest}." + failures=$((failures + 1)) + continue + fi + if [[ "$actual" != "$expected" ]]; then + echo " STALE: ${manifest} digest does not match the pinned v${OPENSHELL_RELEASE_VERSION} release asset." + echo " pinned: ${expected}" + echo " upstream: ${actual}" + failures=$((failures + 1)) + continue + fi + echo " OK: ${manifest} (${actual})" + cat "${workspace}/${manifest}" >>"$manifests" + done + + # invalidState: target-controlled shell formatting hides, duplicates, or + # changes a pin while the trusted release-asset check still reports success. + # sourceBoundary: this parser executes beside the checker only from the + # base-trusted checkout or immutable bootstrap, never from the PR head. It + # defines the accepted static shell subset; PR-head installers are input data + # only and are never sourced or executed. + # whyNotSourceFix: installers need shell-native lookup before dependencies are + # available, and sourcing target-controlled shell here would execute PR code. + # regressionTest: test/installer-hash-check.test.ts covers resilient formatting + # plus missing and ambiguous pins; the workflow contract pins the parser path. + # removalCondition: replace this parser when both installers directly consume + # one canonical machine-readable pin manifest. + parser_errors="${workspace}/pin-parser-errors.txt" + if ! pin_records=$(node --experimental-strip-types \ + "${CHECKER_ROOT}/checks/extract-installer-pins.mts" \ + --release-version "$OPENSHELL_RELEASE_VERSION" \ + --installer "$installer" \ + --brev-installer "$brev_installer" \ + --format tsv 2>"$parser_errors"); then + echo " STALE: unable to extract the OpenShell installer pin tables with trusted parser code." + while IFS= read -r parser_error; do + echo " ${parser_error}" + done <"$parser_errors" + failures=$((failures + 1)) + else + while IFS=$'\t' read -r source asset pinned; do + if [[ "$source" == "installer" ]]; then + count=$((count + 1)) + else + brev_count=$((brev_count + 1)) + fi + matches=$(awk -v asset="$asset" '$2 == asset { count++ } END { print count + 0 }' "$manifests") + upstream=$(awk -v asset="$asset" '$2 == asset { print $1; exit }' "$manifests") + if [[ "$matches" -eq 1 && "$pinned" == "$upstream" ]]; then + published_count=$((published_count + 1)) + echo " OK: ${source} ${asset} (${pinned})" + else + echo " STALE: ${source} ${asset} does not match exactly one v${OPENSHELL_RELEASE_VERSION} checksum entry." + echo " pinned: ${pinned}" + echo " upstream: ${upstream:-missing}" + echo " matches: ${matches}" + failures=$((failures + 1)) + fi + done <<<"$pin_records" + fi -register() { - LABELS+=("$1") - FILES+=("$2") - VARS+=("$3") - URLS+=("$4") + if [[ "$count" -ne 8 ]]; then + echo " STALE: expected 8 pinned OpenShell v${OPENSHELL_RELEASE_VERSION} assets, found ${count}." + failures=$((failures + 1)) + fi + if [[ "$brev_count" -ne 2 ]]; then + echo " STALE: expected 2 pinned Brev OpenShell v${OPENSHELL_RELEASE_VERSION} CLI assets, found ${brev_count}." + failures=$((failures + 1)) + fi + if [[ "$published_count" -ne 10 ]]; then + echo " STALE: expected all 10 pinned asset references in the v${OPENSHELL_RELEASE_VERSION} checksum manifests, matched ${published_count}." + failures=$((failures + 1)) + fi + return "$failures" } -register "Ollama installer" \ - "${REPO_ROOT}/scripts/install.sh" \ - "OLLAMA_INSTALL_SHA256" \ - "https://ollama.com/install.sh" - # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- failures=0 - -for i in "${!LABELS[@]}"; do - label="${LABELS[$i]}" - file="${FILES[$i]}" - var="${VARS[$i]}" - url="${URLS[$i]}" - - pinned=$(extract_pinned "$file" "$var") - - if [[ -z "$pinned" ]]; then - echo " SKIP: ${var} not found in ${file} (not yet merged?)" - continue - fi - - echo "Checking ${label} (${var})..." - echo " Fetching ${url}..." - upstream=$(fetch_hash "$url") - - if [[ "$pinned" == "$upstream" ]]; then - echo " OK: hash is up-to-date (${pinned})" - continue - fi - - if [[ "${1:-}" == "--update" ]]; then - update_pinned "$file" "$pinned" "$upstream" - echo " UPDATED ${file}: ${var}" - echo " old: ${pinned}" - echo " new: ${upstream}" - else - echo " STALE: pinned hash does not match upstream." - echo " pinned: ${pinned}" - echo " upstream: ${upstream}" - failures=$((failures + 1)) - fi -done - -if ((failures > 0)); then - echo "" - echo "${failures} hash(es) are stale. To update, run:" - echo "" - echo " scripts/check-installer-hash.sh --update" +if check_openshell_release_assets; then echo "" - exit 1 + echo "All installer hashes are current." + exit 0 +else + failures=$? fi echo "" -echo "All installer hashes are current." +echo "${failures} OpenShell release-asset check(s) failed." +exit 1 diff --git a/scripts/checks/extract-installer-pins.mts b/scripts/checks/extract-installer-pins.mts new file mode 100644 index 00000000000..e726b085690 --- /dev/null +++ b/scripts/checks/extract-installer-pins.mts @@ -0,0 +1,474 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +type Token = { + kind: "newline" | "operator" | "word"; + value: string; +}; + +export type InstallerPin = { + asset: string; + sha256: string; + source: string; +}; + +type ExtractOptions = { + functionName: string; + releaseVersion: string; + sourceLabel: string; +}; + +type CliOptions = { + brevInstaller: string; + format: "json" | "tsv"; + installer: string; + releaseVersion: string; +}; + +const FUNCTION_LOCAL_PATTERN = /^local release_tag\s*=\s*\$1 asset\s*=\s*\$2$/u; +const LITERAL_PIN_PATTERN = /^v([0-9]+\.[0-9]+\.[0-9]+):([A-Za-z0-9._+-]+)$/u; +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const FUNCTION_SELECTOR_VALUES = new Set(["${release_tag}:${asset}", "$release_tag:$asset"]); +const MAX_INSTALLER_INPUT_BYTES = 1024 * 1024; + +function fail(message: string): never { + throw new Error(`Installer pin extraction failed: ${message}`); +} + +// Pull-request CI executes this parser from a trusted checkout while these +// paths point into the mutable PR tree. Reject links and special files before +// reading, verify that the opened file is still the one inspected, and cap the +// bytes consumed so PR-authored input cannot redirect or exhaust the verifier. +// Regression coverage lives in test/installer-hash-check.test.ts. +function readInstallerInput(inputPath: string, sourceLabel: string): string { + let parentStats: fs.Stats; + try { + parentStats = fs.lstatSync(path.dirname(inputPath)); + } catch { + fail(`${sourceLabel} input parent directory is unavailable`); + } + if (parentStats.isSymbolicLink() || !parentStats.isDirectory()) { + fail(`${sourceLabel} input parent must be a real directory and not a symbolic link`); + } + + let pathStats: fs.Stats; + try { + pathStats = fs.lstatSync(inputPath); + } catch { + fail(`${sourceLabel} input is unavailable`); + } + if (pathStats.isSymbolicLink() || !pathStats.isFile()) { + fail(`${sourceLabel} input must be a regular file and not a symbolic link`); + } + + let descriptor: number; + try { + descriptor = fs.openSync( + inputPath, + fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | fs.constants.O_NOFOLLOW, + ); + } catch { + fail(`${sourceLabel} input must be a regular file and not a symbolic link`); + } + + try { + const openedStats = fs.fstatSync(descriptor); + if ( + !openedStats.isFile() || + openedStats.dev !== pathStats.dev || + openedStats.ino !== pathStats.ino + ) { + fail(`${sourceLabel} input changed during validation or is not a regular file`); + } + if (openedStats.size > MAX_INSTALLER_INPUT_BYTES) { + fail(`${sourceLabel} input exceeds the ${MAX_INSTALLER_INPUT_BYTES}-byte limit`); + } + + const buffer = Buffer.allocUnsafe(MAX_INSTALLER_INPUT_BYTES + 1); + let bytesRead = 0; + while (bytesRead < buffer.length) { + const chunkSize = fs.readSync(descriptor, buffer, bytesRead, buffer.length - bytesRead, null); + if (chunkSize === 0) { + break; + } + bytesRead += chunkSize; + } + if (bytesRead > MAX_INSTALLER_INPUT_BYTES) { + fail(`${sourceLabel} input exceeds the ${MAX_INSTALLER_INPUT_BYTES}-byte limit`); + } + return buffer.subarray(0, bytesRead).toString("utf8"); + } finally { + fs.closeSync(descriptor); + } +} + +function isOperatorStart(character: string): boolean { + return "(){};".includes(character); +} + +function tokenizeShellSubset(source: string): Token[] { + const tokens: Token[] = []; + let index = 0; + + while (index < source.length) { + const character = source[index] ?? ""; + const next = source[index + 1] ?? ""; + + if (character === "\\" && (next === "\n" || (next === "\r" && source[index + 2] === "\n"))) { + index += next === "\n" ? 2 : 3; + continue; + } + if (character === " " || character === "\t" || character === "\r") { + index += 1; + continue; + } + if (character === "\n") { + tokens.push({ kind: "newline", value: "\n" }); + index += 1; + continue; + } + if (character === "#") { + while (index < source.length && source[index] !== "\n") { + index += 1; + } + continue; + } + if (character === ";" && next === ";") { + tokens.push({ kind: "operator", value: ";;" }); + index += 2; + continue; + } + if (isOperatorStart(character)) { + tokens.push({ kind: "operator", value: character }); + index += 1; + continue; + } + + let value = ""; + while (index < source.length) { + const wordCharacter = source[index] ?? ""; + const wordNext = source[index + 1] ?? ""; + if ( + wordCharacter === " " || + wordCharacter === "\t" || + wordCharacter === "\r" || + wordCharacter === "\n" || + isOperatorStart(wordCharacter) + ) { + break; + } + if (wordCharacter === "\\") { + if (wordNext === "\n" || (wordNext === "\r" && source[index + 2] === "\n")) { + index += wordNext === "\n" ? 2 : 3; + continue; + } + if (!wordNext) { + fail("source ends with an incomplete escape"); + } + value += wordNext; + index += 2; + continue; + } + if (wordCharacter === "'") { + const closingQuote = source.indexOf("'", index + 1); + if (closingQuote === -1) { + fail("source contains an unterminated single-quoted word"); + } + value += source.slice(index + 1, closingQuote); + index = closingQuote + 1; + continue; + } + if (wordCharacter === '"') { + index += 1; + let closed = false; + while (index < source.length) { + const quotedCharacter = source[index] ?? ""; + const quotedNext = source[index + 1] ?? ""; + if (quotedCharacter === '"') { + index += 1; + closed = true; + break; + } + if (quotedCharacter === "\\") { + if (quotedNext === "\n" || (quotedNext === "\r" && source[index + 2] === "\n")) { + index += quotedNext === "\n" ? 2 : 3; + continue; + } + if ('$`"\\'.includes(quotedNext)) { + value += quotedNext; + index += 2; + continue; + } + } + value += quotedCharacter; + index += 1; + } + if (!closed) { + fail("source contains an unterminated double-quoted word"); + } + continue; + } + value += wordCharacter; + index += 1; + } + if (!value) { + fail(`unsupported shell token near ${JSON.stringify(source.slice(index, index + 16))}`); + } + tokens.push({ kind: "word", value }); + } + + return tokens; +} + +function isToken(token: Token | undefined, kind: Token["kind"], value?: string): boolean { + return token?.kind === kind && (value === undefined || token.value === value); +} + +function functionBodyRanges(tokens: Token[], functionName: string): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + for (let index = 0; index < tokens.length - 3; index += 1) { + const nameIndex = isToken(tokens[index], "word", "function") ? index + 1 : index; + if (!isToken(tokens[nameIndex], "word", functionName)) { + continue; + } + let cursor = nameIndex + 1; + if (isToken(tokens[cursor], "operator", "(")) { + if (!isToken(tokens[cursor + 1], "operator", ")")) { + continue; + } + cursor += 2; + } + if (!isToken(tokens[cursor], "operator", "{")) { + continue; + } + + let depth = 1; + for (let bodyCursor = cursor + 1; bodyCursor < tokens.length; bodyCursor += 1) { + if (isToken(tokens[bodyCursor], "operator", "{")) { + depth += 1; + } else if (isToken(tokens[bodyCursor], "operator", "}")) { + depth -= 1; + if (depth === 0) { + ranges.push([cursor + 1, bodyCursor]); + index = bodyCursor; + break; + } + } + } + if (depth !== 0) { + fail(`${functionName} has an unterminated function body`); + } + } + return ranges; +} + +function skipSeparators(tokens: Token[], start: number): number { + let cursor = start; + while (isToken(tokens[cursor], "newline") || isToken(tokens[cursor], "operator", ";")) { + cursor += 1; + } + return cursor; +} + +function commandBeforeSeparator( + tokens: Token[], + start: number, +): { command: Token[]; next: number } { + let cursor = start; + while ( + cursor < tokens.length && + !isToken(tokens[cursor], "newline") && + !isToken(tokens[cursor], "operator", ";") + ) { + cursor += 1; + } + return { command: tokens.slice(start, cursor), next: skipSeparators(tokens, cursor) }; +} + +function staticPinFromArm(pattern: string, commandTokens: Token[]): InstallerPin | undefined { + const match = LITERAL_PIN_PATTERN.exec(pattern); + if (!match) { + if (pattern !== "*") { + fail(`unsupported case pattern ${JSON.stringify(pattern)}`); + } + const wildcardCommand = commandTokens + .filter((token) => token.kind !== "newline" && token.value !== ";") + .map((token) => token.value); + if (wildcardCommand.join(" ") !== "return 1") { + fail("the fallback case arm must contain only 'return 1'"); + } + return undefined; + } + + const command = commandTokens + .filter((token) => token.kind !== "newline" && token.value !== ";") + .map((token) => token.value); + if (command.length !== 3 || command[0] !== "printf" || command[1] !== "%s\\n") { + fail(`case arm ${pattern} must contain exactly one static printf '%s\\n' SHA-256 command`); + } + const sha256 = command[2] ?? ""; + if (!SHA256_PATTERN.test(sha256)) { + fail(`case arm ${pattern} does not contain one literal lowercase SHA-256 digest`); + } + return { asset: match[2] ?? "", sha256, source: "" }; +} + +// invalidState: trusted CI accepts a pin table whose shell formatting hides, +// duplicates, or changes a consumed release-asset digest. +// sourceBoundary: this trusted parser owns the accepted static shell subset; +// pull-request installer files provide data only and are never sourced or run. +// whyNotSourceFix: the bootstrap installers need self-contained shell lookup +// functions before package dependencies are available, so JSON is not their +// runtime source of truth. +// regressionTest: test/installer-hash-check.test.ts covers whitespace, comments, +// continuations, quote styles, mixed indentation, missing pins, and ambiguity. +// removalCondition: remove shell parsing when both installers and this verifier +// consume one canonical machine-readable pin manifest directly. +export function extractInstallerPins(source: string, options: ExtractOptions): InstallerPin[] { + const tokens = tokenizeShellSubset(source); + const ranges = functionBodyRanges(tokens, options.functionName); + if (ranges.length !== 1) { + fail(`expected exactly one ${options.functionName} definition, found ${ranges.length}`); + } + const [bodyStart, bodyEnd] = ranges[0] ?? fail(`missing ${options.functionName} body`); + const body = tokens.slice(bodyStart, bodyEnd); + let cursor = skipSeparators(body, 0); + + const local = commandBeforeSeparator(body, cursor); + if (!FUNCTION_LOCAL_PATTERN.test(local.command.map((token) => token.value).join(" "))) { + fail(`${options.functionName} must start with local release_tag and asset inputs`); + } + cursor = local.next; + if (!isToken(body[cursor], "word", "case")) { + fail(`${options.functionName} must contain one static case table`); + } + const selector = body[cursor + 1]; + if (!isToken(selector, "word") || !FUNCTION_SELECTOR_VALUES.has(selector.value)) { + fail(`${options.functionName} must select on release_tag and asset`); + } + if (!isToken(body[cursor + 2], "word", "in")) { + fail(`${options.functionName} case table is missing 'in'`); + } + cursor = skipSeparators(body, cursor + 3); + + const pins: InstallerPin[] = []; + let fallbackCount = 0; + while (!isToken(body[cursor], "word", "esac")) { + const pattern = body[cursor]; + if (!isToken(pattern, "word") || !isToken(body[cursor + 1], "operator", ")")) { + fail(`${options.functionName} contains an invalid case arm`); + } + cursor += 2; + const commandStart = cursor; + while (cursor < body.length && !isToken(body[cursor], "operator", ";;")) { + cursor += 1; + } + if (cursor >= body.length) { + fail(`${options.functionName} case arm ${pattern.value} is missing ';;'`); + } + const pin = staticPinFromArm(pattern.value, body.slice(commandStart, cursor)); + if (pattern.value === "*") { + fallbackCount += 1; + } else if (pin && pattern.value.startsWith(`v${options.releaseVersion}:`)) { + pins.push({ ...pin, source: options.sourceLabel }); + } + cursor = skipSeparators(body, cursor + 1); + } + cursor = skipSeparators(body, cursor + 1); + if (cursor !== body.length) { + fail(`${options.functionName} contains commands after its case table`); + } + if (fallbackCount !== 1) { + fail(`${options.functionName} must contain exactly one fail-closed fallback arm`); + } + + const duplicateAssets = pins + .map((pin) => pin.asset) + .filter((asset, index, assets) => assets.indexOf(asset) !== index); + if (duplicateAssets.length > 0) { + fail( + `${options.functionName} contains duplicate assets: ${[...new Set(duplicateAssets)].join(", ")}`, + ); + } + if (pins.length === 0) { + fail(`${options.functionName} contains no v${options.releaseVersion} pins`); + } + return pins; +} + +function parseCliOptions(argv: string[]): CliOptions { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const option = argv[index] ?? ""; + const value = argv[index + 1] ?? ""; + if (!option.startsWith("--") || !value) { + fail( + "usage: extract-installer-pins.mts --release-version VERSION --installer PATH --brev-installer PATH [--format json|tsv]", + ); + } + if (values.has(option)) { + fail(`duplicate CLI option ${option}`); + } + values.set(option, value); + } + const releaseVersion = values.get("--release-version") ?? ""; + const installer = values.get("--installer") ?? ""; + const brevInstaller = values.get("--brev-installer") ?? ""; + const format = values.get("--format") ?? "json"; + const allowedOptions = new Set([ + "--brev-installer", + "--format", + "--installer", + "--release-version", + ]); + const unknownOptions = [...values.keys()].filter((option) => !allowedOptions.has(option)); + if ( + unknownOptions.length > 0 || + !/^[0-9]+\.[0-9]+\.[0-9]+$/u.test(releaseVersion) || + !installer || + !brevInstaller || + (format !== "json" && format !== "tsv") + ) { + fail(`invalid CLI options${unknownOptions.length > 0 ? `: ${unknownOptions.join(", ")}` : ""}`); + } + return { brevInstaller, format, installer, releaseVersion }; +} + +function runCli(): void { + const options = parseCliOptions(process.argv.slice(2)); + const pins = [ + ...extractInstallerPins(readInstallerInput(options.installer, "installer"), { + functionName: "openshell_pinned_sha256", + releaseVersion: options.releaseVersion, + sourceLabel: "installer", + }), + ...extractInstallerPins(readInstallerInput(options.brevInstaller, "Brev launchable"), { + functionName: "openshell_cli_pinned_sha256", + releaseVersion: options.releaseVersion, + sourceLabel: "Brev launchable", + }), + ]; + if (options.format === "json") { + process.stdout.write(`${JSON.stringify(pins)}\n`); + return; + } + process.stdout.write(pins.map((pin) => `${pin.source}\t${pin.asset}\t${pin.sha256}`).join("\n")); + process.stdout.write("\n"); +} + +const invokedPath = process.argv[1]; +if ( + invokedPath && + fs.realpathSync(path.resolve(invokedPath)) === fs.realpathSync(fileURLToPath(import.meta.url)) +) { + try { + runCli(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/checks/no-coverage-ignore.ts b/scripts/checks/no-coverage-ignore.ts index 23feeae400d..10011145773 100644 --- a/scripts/checks/no-coverage-ignore.ts +++ b/scripts/checks/no-coverage-ignore.ts @@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const SCAN_ROOTS = ["bin", "src", "scripts", "test", "nemoclaw/src"]; -const SOURCE_EXTENSIONS = new Set([".cjs", ".js", ".mjs", ".ts", ".tsx"]); +const SOURCE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".mjs", ".ts", ".tsx"]); const SKIP_DIRS = new Set([".git", "coverage", "dist", "node_modules"]); const FORBIDDEN_DIRECTIVE = ["v8", "ignore"].join(" "); const FORBIDDEN_DIRECTIVE_PATTERN = new RegExp( diff --git a/scripts/checks/openshell-policy-mutation-read.ts b/scripts/checks/openshell-policy-mutation-read.ts new file mode 100644 index 00000000000..cae4139c5bf --- /dev/null +++ b/scripts/checks/openshell-policy-mutation-read.ts @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Prevent provider-composed OpenShell policy entries from entering mutation + * paths. + * + * invalidState: a refactor introduces an unclassified policy read or changes a + * mutation to consume provider-composed `--full` output. + * sourceBoundary: typed command builders own argv construction; this audit owns + * exhaustive discovery and classification of their production call sites. + * whyNotSourceFix: TypeScript cannot distinguish a command array after it + * crosses the process runner, so this defense-in-depth check intentionally uses + * deterministic source patterns plus repository-wide read-site discovery. + * regressionTest: test/policy-mutation-read-discovery.test.ts injects + * unaccounted reads and requires this audit to fail. + * removalCondition: replace the source-pattern table when mutation and + * diagnostic commands carry enforced tagged types through the runner boundary. + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +interface AuditedMutationRead { + readonly relativePath: string; + readonly expectedReadCalls: number; + readonly baseCommand: string; + readonly unsafeBaseCommand?: string; + readonly fullCommand: string; + readonly diagnosticFullRead?: string; +} + +export const MUTATION_READS: readonly AuditedMutationRead[] = [ + { + relativePath: "src/lib/policy/index.ts", + expectedReadCalls: 4, + baseCommand: "runCapture(buildPolicyGetCommand(sandboxName))", + unsafeBaseCommand: "runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true })", + fullCommand: "runCapture(buildPolicyGetFullCommand(sandboxName), { ignoreError: true })", + diagnosticFullRead: "runCapture(buildPolicyGetFullCommand(sandboxName), { ignoreError: true })", + }, + { + relativePath: "nemoclaw/src/blueprint/runner.ts", + expectedReadCalls: 1, + baseCommand: '["openshell", "policy", "get", "--base", sandboxName]', + fullCommand: '["openshell", "policy", "get", "--full", sandboxName]', + }, + { + relativePath: "src/lib/shields/index.ts", + expectedReadCalls: 1, + baseCommand: "runCapture(buildPolicyGetCommand(sandboxName))", + unsafeBaseCommand: "runCapture(buildPolicyGetCommand(sandboxName), {", + fullCommand: "runCapture(buildPolicyGetFullCommand(sandboxName))", + }, +]; + +const NON_MUTATION_POLICY_READS = [ + { + relativePath: "src/lib/actions/sandbox/gateway-state.ts", + expectedReadCalls: 2, + }, + { + relativePath: "src/lib/policy/commands.ts", + expectedReadCalls: 2, + }, +] as const; + +export interface DiscoveredPolicyReadSite { + readonly relativePath: string; + readonly readCalls: number; +} + +const POLICY_GET_BUILDER_CALL = /\bbuildPolicyGet(?:Full)?Command\s*\(/gu; +const DIRECT_POLICY_GET_CALL = + /\[\s*(?:["'`]openshell["'`]\s*,\s*)?["'`]policy["'`]\s*,\s*["'`]get["'`]\s*,\s*["'`]--(?:base|full)["'`]/gu; + +function productionTypeScriptFiles(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return productionTypeScriptFiles(entryPath); + if ( + !entry.isFile() || + !/\.[cm]?ts$/u.test(entry.name) || + /\.(?:test|spec)\.[cm]?ts$/u.test(entry.name) + ) { + return []; + } + return [entryPath]; + }); +} + +export function discoverPolicyReadSites(repoRoot: string): DiscoveredPolicyReadSite[] { + return ["src", "nemoclaw/src"] + .flatMap((sourceRoot) => productionTypeScriptFiles(path.join(repoRoot, sourceRoot))) + .flatMap((sourcePath) => { + const source = readFileSync(sourcePath, "utf8"); + const readCalls = + (source.match(POLICY_GET_BUILDER_CALL) ?? []).length + + (source.match(DIRECT_POLICY_GET_CALL) ?? []).length; + return readCalls > 0 + ? [ + { + relativePath: path.relative(repoRoot, sourcePath).split(path.sep).join("/"), + readCalls, + }, + ] + : []; + }) + .sort((left, right) => left.relativePath.localeCompare(right.relativePath)); +} + +export function auditOpenShellPolicyMutationReads(repoRoot = REPO_ROOT): string[] { + const violations: string[] = []; + for (const { + relativePath, + baseCommand, + unsafeBaseCommand, + fullCommand, + diagnosticFullRead, + } of MUTATION_READS) { + const sourcePath = path.join(repoRoot, relativePath); + if (!existsSync(sourcePath)) { + violations.push(`${relativePath}: audited policy read source is missing`); + continue; + } + const source = readFileSync(sourcePath, "utf8"); + if (!source.includes(baseCommand)) { + violations.push(`${relativePath}: expected the audited policy mutation read to use --base`); + } + if (unsafeBaseCommand && source.includes(unsafeBaseCommand)) { + violations.push(`${relativePath}: policy mutation reads must preserve command failures`); + } + if (!diagnosticFullRead && source.includes(fullCommand)) { + violations.push(`${relativePath}: audited policy mutation read must never use --full output`); + } + if (diagnosticFullRead) { + const diagnosticReads = source.split(diagnosticFullRead).length - 1; + if (!source.includes(fullCommand) || diagnosticReads === 0) { + violations.push(`${relativePath}: expected the audited diagnostic read to use --full`); + } + if (diagnosticReads !== 1) { + violations.push( + `${relativePath}: --full policy reads must remain isolated to the diagnostic path`, + ); + } + } + } + + const discoveredReads = new Map( + discoverPolicyReadSites(repoRoot).map((site) => [site.relativePath, site.readCalls]), + ); + const auditedReads = [...MUTATION_READS, ...NON_MUTATION_POLICY_READS]; + for (const { relativePath, expectedReadCalls } of auditedReads) { + const discoveredCount = discoveredReads.get(relativePath) ?? 0; + if (discoveredCount !== expectedReadCalls) { + violations.push( + `${relativePath}: expected ${expectedReadCalls} audited policy read call(s), found ${discoveredCount}`, + ); + } + discoveredReads.delete(relativePath); + } + for (const [relativePath, readCalls] of discoveredReads) { + violations.push( + `${relativePath}: found ${readCalls} unaccounted policy read call(s); classify every read before merge`, + ); + } + + return violations; +} + +const isEntrypoint = + typeof process.argv[1] === "string" && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isEntrypoint) { + const violations = auditOpenShellPolicyMutationReads(); + if (violations.length > 0) { + console.error(violations.join("\n")); + process.exit(1); + } + + console.log( + "OpenShell policy mutations use --base; read-only diagnostics isolate --full output.", + ); +} diff --git a/scripts/checks/run.ts b/scripts/checks/run.ts index a7272ad31c4..97a251700c4 100644 --- a/scripts/checks/run.ts +++ b/scripts/checks/run.ts @@ -31,6 +31,11 @@ const CHECKS: readonly CheckCommand[] = [ command: TSX, args: ["scripts/checks/no-coverage-ignore.ts"], }, + { + name: "openshell-policy-mutation-read", + command: TSX, + args: ["scripts/checks/openshell-policy-mutation-read.ts"], + }, { name: "layer-import-boundaries", command: TSX, diff --git a/scripts/checks/verify-openshell-policy-boundary-dependencies.mts b/scripts/checks/verify-openshell-policy-boundary-dependencies.mts new file mode 100644 index 00000000000..f8215456c4d --- /dev/null +++ b/scripts/checks/verify-openshell-policy-boundary-dependencies.mts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ALLOWED_POLICY_BOUNDARY_MODULES = new Set(["yaml"]); +const STATIC_REQUIRE = /\brequire\s*\(\s*(["'])([^"'\\\r\n]+)\1\s*\)/g; +const STATIC_IMPORT = /\bimport\s*\(\s*(["'])([^"'\\\r\n]+)\1\s*\)/g; +const ANY_UNCLASSIFIED_REQUIRE = /\brequire\b/; +const ANY_DYNAMIC_IMPORT = /\bimport\s*\(/; + +function collectStaticModules(source: string, pattern: RegExp, modules: string[]): string { + return source.replace(pattern, (_call: string, _quote: string, specifier: string): string => { + modules.push(specifier); + return "/* audited module load */"; + }); +} + +// invalidState: the generated sandbox boundary gains an undeclared or dynamic +// module load that silently expands the trusted runtime dependency surface. +// sourceBoundary: this audit admits only the reviewed direct module set before +// Docker copies the compiled boundary into the runtime image. +// whyNotSourceFix: TypeScript and npm resolve imports independently; neither +// constrains future edits to the security boundary's least-dependency contract. +// regressionTest: test/package-contract/openshell-policy-boundary.test.ts. +// removalCondition: remove only when the build system enforces an equivalent +// per-module dependency allowlist before constructing the sandbox image. +export function auditOpenShellPolicyBoundaryDependencies(source: string): string[] { + const modules: string[] = []; + let unclassifiedSource = collectStaticModules(source, STATIC_REQUIRE, modules); + unclassifiedSource = collectStaticModules(unclassifiedSource, STATIC_IMPORT, modules); + + if ( + ANY_UNCLASSIFIED_REQUIRE.test(unclassifiedSource) || + ANY_DYNAMIC_IMPORT.test(unclassifiedSource) + ) { + throw new Error( + "OpenShell policy boundary contains a non-literal module load; only audited literal imports are allowed", + ); + } + + const disallowed = [...new Set(modules)] + .filter((specifier) => !ALLOWED_POLICY_BOUNDARY_MODULES.has(specifier)) + .sort(); + if (disallowed.length > 0) { + throw new Error( + `OpenShell policy boundary imports non-whitelisted modules: ${disallowed.join(", ")}; allowed: ${[ + ...ALLOWED_POLICY_BOUNDARY_MODULES, + ].join(", ")}`, + ); + } + + return [...new Set(modules)].sort(); +} + +export function auditOpenShellPolicyBoundaryFile(filePath: string): string[] { + return auditOpenShellPolicyBoundaryDependencies(fs.readFileSync(filePath, "utf8")); +} + +function runCli(): void { + const filePath = process.argv[2]; + if (!filePath) { + throw new Error( + "Usage: verify-openshell-policy-boundary-dependencies.mts ", + ); + } + const modules = auditOpenShellPolicyBoundaryFile(filePath); + process.stdout.write( + `Verified OpenShell policy boundary dependencies: ${modules.join(", ") || "none"}\n`, + ); +} + +const invokedPath = process.argv[1]; +if (invokedPath && pathToFileURL(path.resolve(invokedPath)).href === import.meta.url) { + try { + runCli(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index 6de8b125f13..ecb81c1c932 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -33,18 +33,20 @@ esac info "Detected $OS_LABEL ($ARCH_LABEL)" -# Minimum version required for native messaging credential rewrite: -# WebSocket text frames plus provider-shaped aliases and REST request bodies. -MIN_VERSION="0.0.71" +# Minimum version required for native messaging credential rewrite and +# round-trippable base policies: WebSocket text frames, provider-shaped +# aliases, REST request bodies, and `policy get --base` for MCP/JSON-RPC-safe +# read-modify-write operations. +MIN_VERSION="0.0.72" # Maximum version validated for this NemoClaw release. Newer OpenShell builds # may change sandbox semantics; upgrade NemoClaw before upgrading past this. -MAX_VERSION="0.0.71" +MAX_VERSION="0.0.72" # Pin fresh installs to this version. The TS installer normally overrides this # via NEMOCLAW_OPENSHELL_PIN_VERSION after resolving the highest published # OpenShell release that satisfies the blueprint's max_openshell_version # (see #3404). The hardcoded value is the fallback for offline runs. PIN_VERSION="$MAX_VERSION" -DEV_MIN_VERSION="0.0.71" +DEV_MIN_VERSION="0.0.72" CHANNEL="${NEMOCLAW_OPENSHELL_CHANNEL:-auto}" case "$CHANNEL" in @@ -59,8 +61,8 @@ else fi if [ "$RESOLVED_CHANNEL" = "dev" ]; then - if [ "${NEMOCLAW_ALLOW_DEV_NO_VERIFY:-}" != "1" ]; then - fail "Dev channel install skips SHA-256 verification. Set NEMOCLAW_ALLOW_DEV_NO_VERIFY=1 to allow unverified OpenShell dev-channel installs." + if [ "${NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL:-}" != "1" ]; then + fail "Dev channel install skips SHA-256 verification. Set NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL=1 to explicitly accept an unverified OpenShell dev-channel install." fi warn "Dev channel install skips SHA-256 verification. Use only in trusted environments." fi @@ -109,32 +111,43 @@ else RELEASE_TAG="v${PIN_VERSION}" fi +# invalidState: a consumed OpenShell release asset differs from the digest +# published for the immutable v0.0.72 release, or a mutable registry tag moves. +# sourceBoundary: NVIDIA/OpenShell owns the release workflow, GitHub release +# assets, and GHCR manifests; NemoClaw owns which exact artifacts it trusts. +# whyNotSourceFix: NemoClaw cannot retroactively make an upstream publication +# immutable, so it independently pins every consumed archive and supervisor. +# regressionTest: test/install-openshell-version-check.test.ts exercises all +# eight mappings, and scripts/check-installer-hash.sh compares them with the +# GitHub release API on every PR, main push, weekly run, and manual dispatch. +# removalCondition: remove these v0.0.72 entries only when NemoClaw drops that +# supported release or replaces them with independently verified newer pins. openshell_pinned_sha256() { local release_tag="$1" asset="$2" case "${release_tag}:${asset}" in - v0.0.71:openshell-x86_64-unknown-linux-musl.tar.gz) - printf '%s\n' "b71e3a7fb6973c7c353521f88740885e6e661a199b6355140d45f4f8ab72d716" + v0.0.72:openshell-x86_64-unknown-linux-musl.tar.gz) + printf '%s\n' "37836c3b50383e03249c5e16512c1806e591fba8451408a84fb2f628ddb318c4" ;; - v0.0.71:openshell-aarch64-unknown-linux-musl.tar.gz) - printf '%s\n' "b86b33d9e7c960cd04bc99a9539964f1cb84ae4a9886dd437c0566b64e093390" + v0.0.72:openshell-aarch64-unknown-linux-musl.tar.gz) + printf '%s\n' "a5ff01a3240d73c72ec1700eda6cc6c752a86cf50c5dd1b5bdc459f544d03045" ;; - v0.0.71:openshell-aarch64-apple-darwin.tar.gz) - printf '%s\n' "1ef9a2b447a35391a6a0f417f4383d99f3e928e443cf86ed190002ec937a8871" + v0.0.72:openshell-aarch64-apple-darwin.tar.gz) + printf '%s\n' "117b5354cc42d80bc4d5e070ea5ac4e341208ff6d3c29b516d8a9c80e2310f8d" ;; - v0.0.71:openshell-gateway-x86_64-unknown-linux-gnu.tar.gz) - printf '%s\n' "85fe7c9d939cb2d32389182e816ac388ee1c95dbf5dae1c3dcd37d5bd979db7d" + v0.0.72:openshell-gateway-x86_64-unknown-linux-gnu.tar.gz) + printf '%s\n' "03225fb9388b682af1a5f1614b26b75f828da6031e3ffc1fd920b6fbe5f70877" ;; - v0.0.71:openshell-gateway-aarch64-unknown-linux-gnu.tar.gz) - printf '%s\n' "e9b258b3fb38fd68ffc37675efe8a027750087f630cf19ad248e94eff5464091" + v0.0.72:openshell-gateway-aarch64-unknown-linux-gnu.tar.gz) + printf '%s\n' "a97dcb3acb04fb2d1170c1a2170228990c2337e25bb8c18817e5a6e952204108" ;; - v0.0.71:openshell-gateway-aarch64-apple-darwin.tar.gz) - printf '%s\n' "26fa5b4dcb6d2631f7212639d087f37d8b0fc50c6f6cec856e019c22847e5bc9" + v0.0.72:openshell-gateway-aarch64-apple-darwin.tar.gz) + printf '%s\n' "8c07362107393eb5f4ae4b9ee9f4257fd53862c51ad8dd96f2fe31bb6d8d7ffb" ;; - v0.0.71:openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz) - printf '%s\n' "dbf7fffb285e9ffca7ffd439118b7aadd4e5c4df45c73f0fff89fcca9b19c47d" + v0.0.72:openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz) + printf '%s\n' "811f914b6a6a3a3f4533449ddebebb6422333861a27a5fa848db6cbfdffdd230" ;; - v0.0.71:openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz) - printf '%s\n' "e60dc50524c56460faa8c37617725280a6e1205e73e5cc888b4fd0d148ccb71c" + v0.0.72:openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz) + printf '%s\n' "2cf62cbd651e55d0f8750804e2b4025e0d6c8eea4564c87cda47a2c922941db0" ;; *) return 1 @@ -321,7 +334,7 @@ if command -v openshell >/dev/null 2>&1; then elif ! openshell_has_required_messaging_features; then fail "${OPENSHELL_FEATURE_CHECK_ERROR:-openshell $INSTALLED_VERSION is missing required messaging credential rewrite support. Install an OpenShell build that includes provider aliases, WebSocket text rewrite, and request-body credential rewrite.}" else - info "openshell already installed: $INSTALLED_VERSION (>= $MIN_VERSION, <= $MAX_VERSION, messaging rewrite capable)" + info "openshell already installed: $INSTALLED_VERSION (>= $MIN_VERSION, <= $MAX_VERSION, messaging rewrite and policy --base capable)" exit 0 fi else diff --git a/src/lib/actions/sandbox/forward-recovery.ts b/src/lib/actions/sandbox/forward-recovery.ts index fc5713dfa9c..35aee576f87 100644 --- a/src/lib/actions/sandbox/forward-recovery.ts +++ b/src/lib/actions/sandbox/forward-recovery.ts @@ -94,9 +94,11 @@ export function isSandboxPortForwardHealthy( } export function ensureSandboxPortForwardForPort(sandboxName: string, port: number): boolean { - const forwardHealth = isSandboxPortForwardHealthy(sandboxName, port); + let forwardHealth = isSandboxPortForwardHealthy(sandboxName, port); if (forwardHealth === true) return true; if (forwardHealth === "occupied") return false; + const configuredWaitMs = Number(process.env.NEMOCLAW_FORWARD_RECOVERY_WAIT_MS ?? "3000"); + const waitMs = Number.isFinite(configuredWaitMs) ? Math.max(0, configuredWaitMs) : 3000; const stopResult = runOpenshell(["forward", "stop", String(port), sandboxName], { ignoreError: true, @@ -107,6 +109,43 @@ export function ensureSandboxPortForwardForPort(sandboxName: string, port: numbe ` Warning: openshell forward stop ${port} ${sandboxName} exited ${stopResult.status}; attempting restart anyway.`, ); } + + // OpenShell v0.0.72 removes the forward PID file shortly after SIGTERM, + // before the old SSH listener is guaranteed to release its host port. A + // blind stop -> start can therefore collide with the just-stopped process. + // Preserve authoritative owner metadata while waiting: accept a target- + // owned forward that recovered on its own, reject another sandbox, and only + // start after an otherwise-unowned local listener has actually quiesced. + // NemoClaw must compensate while the already-released OpenShell 0.0.72 + // contract remains supported; test/process-recovery.test.ts locks both the + // delayed-release and fail-closed cases. Remove this wait only after every + // supported OpenShell release either waits for host-listener release before + // `forward stop` returns or exposes an authoritative listener-released state + // that this path consumes instead. + if (waitMs > 0 && isLocalForwardReachable(port)) { + const stopState: { health: SandboxForwardHealth; portReleased: boolean } = { + health: forwardHealth, + portReleased: false, + }; + const stopSettled = waitUntil( + () => { + stopState.health = isSandboxPortForwardHealthy(sandboxName, port); + stopState.portReleased = !isLocalForwardReachable(port); + return ( + stopState.health === true || stopState.health === "occupied" || stopState.portReleased + ); + }, + { + deadlineMs: Date.now() + waitMs, + initialIntervalMs: 100, + maxIntervalMs: 500, + backoffFactor: 1.5, + }, + ); + if (stopState.health === true) return true; + if (stopState.health === "occupied" || !stopSettled || !stopState.portReleased) return false; + } + const startResult = runOpenshell( ["forward", "start", "--background", String(port), sandboxName], { @@ -122,8 +161,6 @@ export function ensureSandboxPortForwardForPort(sandboxName: string, port: numbe let health = isSandboxPortForwardHealthy(sandboxName, port); if (health === true) return true; if (health === "occupied") return false; - const configuredWaitMs = Number(process.env.NEMOCLAW_FORWARD_RECOVERY_WAIT_MS ?? "3000"); - const waitMs = Number.isFinite(configuredWaitMs) ? Math.max(0, configuredWaitMs) : 3000; if (waitMs === 0) return false; let occupied = false; diff --git a/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts b/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts index 65969d93d80..ac8fc396ab4 100644 --- a/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts +++ b/src/lib/actions/sandbox/rebuild-gateway-drift.test.ts @@ -100,7 +100,7 @@ describe("rebuild gateway drift preflight", () => { ); ({ rebuildSandbox } = requireDist("./rebuild.js")); - }); + }, 30_000); afterEach(() => { for (const spy of spies) spy.mockRestore(); diff --git a/src/lib/adapters/openshell/client.test.ts b/src/lib/adapters/openshell/client.test.ts index ec41f409f53..e781334de20 100644 --- a/src/lib/adapters/openshell/client.test.ts +++ b/src/lib/adapters/openshell/client.test.ts @@ -57,6 +57,7 @@ describe("openshell helpers", () => { it("parses semantic versions from CLI output", () => { expect(parseVersionFromText("openshell 0.0.9")).toBe("0.0.9"); expect(parseVersionFromText("v1.2.3\n")).toBe("1.2.3"); + expect(parseVersionFromText("Hermes Agent v0.17.0 (2026.6.19)")).toBe("0.17.0"); expect(parseVersionFromText("no version here")).toBeNull(); }); diff --git a/src/lib/onboard/docker-driver-gateway-compat-container.test.ts b/src/lib/onboard/docker-driver-gateway-compat-container.test.ts index d074b5cf7f7..f578f39c619 100644 --- a/src/lib/onboard/docker-driver-gateway-compat-container.test.ts +++ b/src/lib/onboard/docker-driver-gateway-compat-container.test.ts @@ -21,7 +21,7 @@ import { resolveDriftGatewayBin, } from "./docker-driver-gateway-launch"; -const PINNED_COMPAT_IMAGE_OVERRIDE = `registry.example/nemoclaw/gateway-compat:0.0.71@sha256:${"a".repeat( +const PINNED_COMPAT_IMAGE_OVERRIDE = `registry.example/nemoclaw/gateway-compat:0.0.72@sha256:${"a".repeat( 64, )}`; @@ -234,7 +234,7 @@ describe("docker-driver-gateway compatibility container", () => { " Compatibility gateway bind: 127.0.0.1 main listener plus OpenShell Docker-driver bridge reachability.", ); expect(warnings).toEqual([ - " SECURITY NOTICE: compatibility container uses host networking plus Docker API access; enabled only by NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1. Review/removal conditions: docs/security/openshell-0.0.71-gateway-auth-review.mdx#source-of-truth-boundaries.", + " SECURITY NOTICE: compatibility container uses host networking plus Docker API access; enabled only by NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1. Review/removal conditions: docs/security/openshell-0.0.72-compatibility-review.mdx#source-of-truth-boundaries.", ]); expect(messages).toContain( " Gateway auth boundary: host-side OpenShell CLI uses local mTLS; sandbox callbacks use mTLS plus OpenShell gateway JWT.", diff --git a/src/lib/onboard/docker-driver-gateway-compat.ts b/src/lib/onboard/docker-driver-gateway-compat.ts index abb5bd48cd1..f3d5f1355d0 100644 --- a/src/lib/onboard/docker-driver-gateway-compat.ts +++ b/src/lib/onboard/docker-driver-gateway-compat.ts @@ -318,7 +318,7 @@ export function logContainerizedDockerDriverGatewayLaunch( log(` OpenShell gateway compatibility patch active (${launch.reason}).`); log(" Running openshell-gateway inside a Docker compatibility container."); warn( - " SECURITY NOTICE: compatibility container uses host networking plus Docker API access; enabled only by NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1. Review/removal conditions: docs/security/openshell-0.0.71-gateway-auth-review.mdx#source-of-truth-boundaries.", + " SECURITY NOTICE: compatibility container uses host networking plus Docker API access; enabled only by NEMOCLAW_OPENSHELL_GATEWAY_CONTAINER_PATCH=1. Review/removal conditions: docs/security/openshell-0.0.72-compatibility-review.mdx#source-of-truth-boundaries.", ); log( " Compatibility gateway bind: 127.0.0.1 main listener plus OpenShell Docker-driver bridge reachability.", diff --git a/src/lib/onboard/docker-driver-gateway-config-auth-contract.test.ts b/src/lib/onboard/docker-driver-gateway-config-auth-contract.test.ts index 8dd662e8010..3be0c5c0f86 100644 --- a/src/lib/onboard/docker-driver-gateway-config-auth-contract.test.ts +++ b/src/lib/onboard/docker-driver-gateway-config-auth-contract.test.ts @@ -19,14 +19,50 @@ import { } from "../../../test/support/openshell-gateway-config-helpers"; describe("docker-driver-gateway auth contract", () => { - it("records the audited OpenShell 0.0.71 source revision", () => { - const reviewNote = fs.readFileSync(GATEWAY_AUTH_REVIEW_NOTE, "utf-8"); - - expect(reviewNote).toContain("NVIDIA/OpenShell@v0.0.71"); - expect(reviewNote).toContain("a242f84bb367d6df7d4d133e95a93857406c67f7"); + it("keeps the OpenShell gateway auth source review aligned with the generated config", () => { + const compatibilityReview = fs.readFileSync(GATEWAY_AUTH_REVIEW_NOTE, "utf-8"); + const inheritedAuthReview = fs.readFileSync( + path.join(path.dirname(GATEWAY_AUTH_REVIEW_NOTE), "openshell-0.0.71-gateway-auth-review.mdx"), + "utf-8", + ); + + expect(compatibilityReview).toContain("NVIDIA/OpenShell@v0.0.72"); + expect(compatibilityReview).toContain("8cb16de9eae4c44d7d31e1493747d8c10abb5963"); + expect(compatibilityReview).toContain("OpenShell 0.0.71 gateway authentication review"); + expect(compatibilityReview).toContain( + "https://github.com/NVIDIA/OpenShell/actions/runs/28382086068", + ); + expect(compatibilityReview).toContain( + "supervisor@sha256:80ed9cda5bf672fefdb9dcd4604b40a8b09c0891b6eb9d03e10227c7e3dfb49d", + ); + expect(compatibilityReview).toContain("openshell-gateway-auth-source-contract.test.ts"); + expect(compatibilityReview).toContain("OPENSHELL_DISABLE_GATEWAY_AUTH=true"); + expect(compatibilityReview).toContain("Round-Trippable Policy Boundary"); + expect(compatibilityReview).toContain("openshell policy get --base "); + expect(compatibilityReview).toContain("_provider_*"); + expect(compatibilityReview).toContain("protocol: mcp"); + expect(compatibilityReview).toContain("protocol: json-rpc"); + + expect(inheritedAuthReview).toContain("openshell_server::config_file::load()"); + expect(inheritedAuthReview).toContain("allow_unauthenticated_users"); + expect(inheritedAuthReview).toContain("gateway_jwt"); + expect(inheritedAuthReview).toContain("host-side OpenShell CLI user calls use local mTLS"); + expect(inheritedAuthReview).toContain( + "gateway_listener_addresses_include_driver_address_on_distinct_ip", + ); + expect(inheritedAuthReview).toContain("container_visible_endpoint_rewrites_loopback_hosts"); + expect(inheritedAuthReview).toContain( + "docker_gateway_route_uses_bridge_gateway_for_linux_docker", + ); + expect(inheritedAuthReview).toContain( + "NEMOCLAW_OPENSHELL_GATEWAY_COMPAT_BIND_ADDRESS=0.0.0.0` is rejected", + ); + expect(inheritedAuthReview).toContain("reject `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0`"); + expect(inheritedAuthReview).toContain("OpenShell gateway auth source contract"); + expect(inheritedAuthReview).toContain("valid sandbox JWT access from Docker origin"); }); - it("emits an OpenShell 0.0.71-compatible sandbox JWT bundle and TTL contract", () => { + it("emits an OpenShell 0.0.72-compatible sandbox JWT bundle and TTL contract", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-config-")); try { const env = writeGatewayConfig(stateDir); @@ -124,7 +160,7 @@ describe("docker-driver-gateway auth contract", () => { } }); - it("emits the complete OpenShell 0.0.71 gateway auth TOML schema", () => { + it("emits the complete OpenShell 0.0.72 gateway auth TOML schema", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-config-")); try { const env = writeGatewayConfig(stateDir); diff --git a/src/lib/onboard/docker-driver-gateway-config-toml.test.ts b/src/lib/onboard/docker-driver-gateway-config-toml.test.ts index b22aa771735..370ebcbfdad 100644 --- a/src/lib/onboard/docker-driver-gateway-config-toml.test.ts +++ b/src/lib/onboard/docker-driver-gateway-config-toml.test.ts @@ -13,7 +13,7 @@ import { } from "../../../test/support/openshell-gateway-config-helpers"; describe("docker-driver-gateway config TOML", () => { - it("writes OpenShell 0.0.71 gateway JWT config into the managed state dir", () => { + it("writes OpenShell 0.0.72 gateway JWT config into the managed state dir", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-config-")); try { const env = writeGatewayConfig(stateDir); diff --git a/src/lib/onboard/docker-driver-gateway-config.ts b/src/lib/onboard/docker-driver-gateway-config.ts index 5b34b4dab66..0c6e2f41418 100644 --- a/src/lib/onboard/docker-driver-gateway-config.ts +++ b/src/lib/onboard/docker-driver-gateway-config.ts @@ -12,7 +12,7 @@ import { export type { DockerDriverGatewayJwtBundle } from "./docker-driver-gateway-jwt-bundle"; export { ensureDockerDriverGatewayJwtBundle } from "./docker-driver-gateway-jwt-bundle"; -// See docs/security/openshell-0.0.71-gateway-auth-review.mdx for the source-of-truth review. +// See docs/security/openshell-0.0.72-compatibility-review.mdx for the source-of-truth review. export const DOCKER_DRIVER_GATEWAY_CONFIG_NAME = "openshell-gateway.toml"; export const DOCKER_DRIVER_GATEWAY_JWT_TTL_SECS = 3600; diff --git a/src/lib/onboard/docker-driver-gateway-env-deb-override.test.ts b/src/lib/onboard/docker-driver-gateway-env-deb-override.test.ts index 80df4bf4631..5b9d79b6a01 100644 --- a/src/lib/onboard/docker-driver-gateway-env-deb-override.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env-deb-override.test.ts @@ -63,7 +63,7 @@ describe("buildDockerGatewayDebEnvFile", () => { expect(next).toBe("OPENSHELL_DRIVERS=docker\n"); }); - it("removes stale auth-disable env so OpenShell 0.0.71 TOML auth policy stays authoritative", () => { + it("removes stale auth-disable env so OpenShell 0.0.72 TOML auth policy stays authoritative", () => { const next = buildDockerGatewayDebEnvFile( [ "KEEP_ME=1", diff --git a/src/lib/onboard/docker-driver-gateway-local-tls.ts b/src/lib/onboard/docker-driver-gateway-local-tls.ts index bbed2b77145..703e1d5f465 100644 --- a/src/lib/onboard/docker-driver-gateway-local-tls.ts +++ b/src/lib/onboard/docker-driver-gateway-local-tls.ts @@ -6,7 +6,7 @@ import { createPrivateKey, createPublicKey, type KeyObject, X509Certificate } fr import fs from "node:fs"; import path from "node:path"; -// See docs/security/openshell-0.0.71-gateway-auth-review.mdx for the source-of-truth review. +// See docs/security/openshell-0.0.72-compatibility-review.mdx for the source-of-truth review. export const DOCKER_DRIVER_GATEWAY_LOCAL_TLS_DIR_NAME = "tls"; const REQUIRED_SERVER_DNS_SANS = ["host.openshell.internal", "localhost"]; diff --git a/src/lib/onboard/docker-driver-gateway-runtime.test.ts b/src/lib/onboard/docker-driver-gateway-runtime.test.ts index 58485bee723..8e5682fc14b 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.test.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.test.ts @@ -114,6 +114,22 @@ describe("docker-driver gateway runtime helpers", () => { } }); + it("pins the stable 0.0.72 supervisor default while preserving an explicit override", () => { + const image = (fallback: string) => + makeHelpers({ + getBlueprintMaxOpenshellVersion: () => "0.0.72", + supportedOpenshellFallbackVersion: fallback, + }).helpers.getDockerDriverGatewayEnv(null, "linux").OPENSHELL_DOCKER_SUPERVISOR_IMAGE; + const stable = withEnv({ OPENSHELL_DOCKER_SUPERVISOR_IMAGE: undefined }, () => image("0.0.72")); + expect(stable).toBe( + "ghcr.io/nvidia/openshell/supervisor@sha256:80ed9cda5bf672fefdb9dcd4604b40a8b09c0891b6eb9d03e10227c7e3dfb49d", + ); + const override = "registry.example.test/supervisor@sha256:override"; + expect(withEnv({ OPENSHELL_DOCKER_SUPERVISOR_IMAGE: override }, () => image("0.0.72"))).toBe( + override, + ); + }); + it("clears custom state-dir PID and marker files when the recorded PID is not the gateway", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-runtime-")); const pid = 9_876_543; diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index 4960a8729e5..a2476c0d7dd 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -17,6 +17,10 @@ import * as gatewayBinding from "./gateway-binding"; import type { PortProbeResult } from "./preflight"; import * as vmDriverProcess from "./vm-driver-process"; +const OPENSHELL_SUPERVISOR_MANIFEST_DIGESTS: Readonly> = { + "0.0.72": "sha256:80ed9cda5bf672fefdb9dcd4604b40a8b09c0891b6eb9d03e10227c7e3dfb49d", +}; + export type DockerDriverGatewayRuntimeDrift = { reason: string }; type RunCapture = (args: string[], opts?: { ignoreError?: boolean }) => string; @@ -163,7 +167,10 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa installedVersion ?? deps.getBlueprintMaxOpenshellVersion() ?? deps.supportedOpenshellFallbackVersion; - return `ghcr.io/nvidia/openshell/supervisor:${supportedVersion}`; + const manifestDigest = OPENSHELL_SUPERVISOR_MANIFEST_DIGESTS[supportedVersion]; + return manifestDigest + ? `ghcr.io/nvidia/openshell/supervisor@${manifestDigest}` + : `ghcr.io/nvidia/openshell/supervisor:${supportedVersion}`; } function getDockerDriverGatewayEnv( diff --git a/src/lib/onboard/openshell-install.ts b/src/lib/onboard/openshell-install.ts index 863debbeb8a..1a1e4c8133c 100644 --- a/src/lib/onboard/openshell-install.ts +++ b/src/lib/onboard/openshell-install.ts @@ -159,7 +159,7 @@ export function ensureOpenshellForOnboard(deps: OpenShellInstallDeps): OpenShell deps.exit(1); } } else { - const minOpenshellVersion = deps.getBlueprintMinOpenshellVersion() ?? "0.0.71"; + const minOpenshellVersion = deps.getBlueprintMinOpenshellVersion() ?? "0.0.72"; const currentVersionOutput = deps.runCaptureOpenshell(["--version"], { ignoreError: true }); const needsDevChannel = deps.isLinuxDockerDriverGatewayEnabled(platform, arch) && diff --git a/src/lib/onboard/openshell-version.ts b/src/lib/onboard/openshell-version.ts index 3cab5d2d8e1..ff21b01d6c1 100644 --- a/src/lib/onboard/openshell-version.ts +++ b/src/lib/onboard/openshell-version.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { resolveOpenshell } from "../adapters/openshell/resolve"; import { ROOT, runCapture } from "../runner"; -export const SUPPORTED_OPENSHELL_FALLBACK_VERSION = "0.0.71"; +export const SUPPORTED_OPENSHELL_FALLBACK_VERSION = "0.0.72"; export function getInstalledOpenshellVersion(versionOutput: string | null = null): string | null { const openshellBin = resolveOpenshell(); diff --git a/src/lib/policy/commands.ts b/src/lib/policy/commands.ts new file mode 100644 index 00000000000..6016139d642 --- /dev/null +++ b/src/lib/policy/commands.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Late binding keeps tests able to replace the resolver without rewiring +// command builders that are shared by policy and Shields flows. +const openshellResolveModule = + require("../adapters/openshell/resolve") as typeof import("../adapters/openshell/resolve"); + +function resolveOpenshellBinary(): string { + return openshellResolveModule.resolveOpenshell() ?? "openshell"; +} + +export function buildPolicySetCommand(policyFile: string, sandboxName: string): string[] { + return [resolveOpenshellBinary(), "policy", "set", "--policy", policyFile, "--wait", sandboxName]; +} + +/** Read the round-trippable base policy before a mutation. */ +export function buildPolicyGetCommand(sandboxName: string): string[] { + return [resolveOpenshellBinary(), "policy", "get", "--base", sandboxName]; +} + +/** Read the effective policy for status and other diagnostics. */ +export function buildPolicyGetFullCommand(sandboxName: string): string[] { + return [resolveOpenshellBinary(), "policy", "get", "--full", sandboxName]; +} diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index c3019dfd4f7..3602cd746a9 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -10,6 +10,16 @@ import { listBuiltInMessagingChannelManifests, listMessagingPolicyPresetMetadata, } from "../messaging/channels"; +import { + buildPolicyGetCommand, + buildPolicyGetFullCommand, + buildPolicySetCommand, +} from "./commands"; +import { + parseOpenShellPolicy, + stripProviderComposedPolicies, + withoutProviderComposedPolicies, +} from "./merge"; const fs = require("fs"); const path = require("path"); @@ -97,6 +107,14 @@ function isPolicyObject(value: PolicyValue): value is PolicyObject { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isPresetPolicyMap(value: PolicyValue): value is PolicyObject { + return ( + isPolicyObject(value) && + Object.keys(value).length > 0 && + Object.values(value).every(isPolicyObject) + ); +} + function parseNetworkPolicies(content: string | null | undefined): PolicyObject | null { if (!content) return null; try { @@ -335,42 +353,27 @@ function extractPresetEntries(presetContent: string | null | undefined): string } /** - * Parse the output of `openshell policy get --full` which has a metadata - * header (Version, Hash, etc.) followed by `---` and then the actual YAML. + * Parse the output of `openshell policy get --base` or `--full`, which has a + * metadata header (Version, Hash, etc.) followed by `---` and then the actual + * YAML. */ -function parseCurrentPolicy(raw: string | null | undefined): string { +// invalidState: metadata-only, diagnostic, malformed, or empty CLI output is +// not a policy and must remain distinguishable from a parsed YAML mapping. +// sourceBoundary: OpenShell owns CLI output; the canonical parser owns what +// NemoClaw admits as policy YAML. +// whyNotSourceFix: NemoClaw supports CLI releases whose process output is the +// only available boundary, including versionless network_policies bodies. +// regressionTest: nemoclaw/src/shared/openshell-policy-boundary.test.ts and +// test/policy-mutation-read-failure.test.ts. +// removalCondition: remove this fail-soft adapter when every caller consumes a +// typed OpenShell policy API. +function parseCurrentPolicyOrEmpty(raw: string | null | undefined): string { if (!raw) return ""; - const sep = raw.indexOf("---"); - const candidate = (sep === -1 ? raw : raw.slice(sep + 3)).trim(); - if (!candidate) return ""; - if (/^(error|failed|invalid|warning|status)\b/i.test(candidate)) { - return ""; - } - if (!/^[a-z_][a-z0-9_]*\s*:/m.test(candidate)) { - return ""; - } try { - const parsed = YAML.parse(candidate); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return ""; - } + return parseOpenShellPolicy(raw).yamlBody; } catch { return ""; } - return candidate; -} - -/** - * Resolve the openshell binary, preferring an absolute path so spawnSync does - * not raise ENOENT in non-interactive shells where ~/.local/bin/ is absent - * from PATH (issue #4224). Falls back to the literal "openshell" so callers - * that build argv at module scope (or in tests that only check argv shape) - * don't side-effect on a missing binary; command entry points call - * `assertOpenshellResolvable()` before invoking openshell to surface the - * actionable diagnostic. - */ -function resolveOpenshellBinary(): string { - return openshellResolveModule.resolveOpenshell() ?? "openshell"; } /** @@ -409,60 +412,10 @@ function assertOpenshellResolvable(): void { process.exit(1); } -/** - * Build the openshell policy set command as an argv array. - */ -function buildPolicySetCommand(policyFile: string, sandboxName: string): string[] { - return [resolveOpenshellBinary(), "policy", "set", "--policy", policyFile, "--wait", sandboxName]; -} - -/** - * Build the openshell policy get command as an argv array. - */ -function buildPolicyGetCommand(sandboxName: string): string[] { - return [resolveOpenshellBinary(), "policy", "get", "--full", sandboxName]; -} - -/** - * Text-based fallback for merging preset entries into policy YAML. - * Used when preset entries cannot be parsed as structured YAML. - */ -function textBasedMerge(currentPolicy: string, presetEntries: string): string { - if (!currentPolicy) { - return "version: 1\n\nnetwork_policies:\n" + presetEntries; - } - let merged; - if (/^network_policies\s*:/m.test(currentPolicy)) { - const lines = currentPolicy.split("\n"); - const result = []; - let inNp = false; - let inserted = false; - for (const line of lines) { - if (/^network_policies\s*:/.test(line)) { - inNp = true; - result.push(line); - continue; - } - if (inNp && /^\S.*:/.test(line) && !inserted) { - result.push(presetEntries); - inserted = true; - inNp = false; - } - result.push(line); - } - if (inNp && !inserted) result.push(presetEntries); - merged = result.join("\n"); - } else { - merged = currentPolicy.trimEnd() + "\n\nnetwork_policies:\n" + presetEntries; - } - if (!merged.trimStart().startsWith("version:")) merged = "version: 1\n\n" + merged; - return merged; -} - /** * Merge preset entries into existing policy YAML using structured YAML - * parsing. Replaces the previous text-based manipulation which could - * produce invalid YAML when indentation or ordering varied. + * parsing. Invalid input fails closed instead of falling back to text + * manipulation that could produce a syntactically valid but unsafe policy. * * Behavior: * - Parses both current policy and preset entries as YAML @@ -475,26 +428,33 @@ function textBasedMerge(currentPolicy: string, presetEntries: string): string { * @returns {string} Merged YAML */ function mergePresetIntoPolicy(currentPolicy: string, presetEntries: string): string { - const normalizedCurrentPolicy = parseCurrentPolicy(currentPolicy); + const parsedCurrentPolicy = parseCurrentPolicyOrEmpty(currentPolicy); + if (currentPolicy.trim() && !parsedCurrentPolicy) { + throw new Error( + "Cannot merge policy preset: the current policy is not a valid YAML mapping. " + + "Re-read the base policy and try again; no policy changes were made.", + ); + } + const normalizedCurrentPolicy = stripProviderComposedPolicies(parsedCurrentPolicy); if (!presetEntries) { return normalizedCurrentPolicy || "version: 1\n\nnetwork_policies:\n"; } // Parse preset entries. They come as indented content under network_policies:, // so we wrap them to make valid YAML for parsing. - let presetPolicies; + let presetPolicies: PolicyObject; try { const wrapped = "network_policies:\n" + presetEntries; const parsed = YAML.parse(wrapped); - presetPolicies = parsed?.network_policies; + if (!isPolicyDocument(parsed) || !isPresetPolicyMap(parsed.network_policies)) { + throw new Error("network_policies must be a non-empty mapping of policy objects"); + } + presetPolicies = withoutProviderComposedPolicies(parsed.network_policies); } catch { - presetPolicies = null; - } - - // If YAML parsing failed or entries are not a mergeable object, - // fall back to the text-based approach for backward compatibility. - if (!presetPolicies || typeof presetPolicies !== "object" || Array.isArray(presetPolicies)) { - return textBasedMerge(normalizedCurrentPolicy, presetEntries); + throw new Error( + "Cannot merge policy preset: preset network_policies entries must be a valid YAML mapping. " + + "Check the preset file and try again; no policy changes were made.", + ); } if (!normalizedCurrentPolicy) { @@ -505,9 +465,15 @@ function mergePresetIntoPolicy(currentPolicy: string, presetEntries: string): st let current: PolicyDocument | null; try { const parsed = YAML.parse(normalizedCurrentPolicy); - current = isPolicyDocument(parsed) ? parsed : {}; + current = isPolicyDocument(parsed) ? parsed : null; } catch { - return textBasedMerge(normalizedCurrentPolicy, presetEntries); + current = null; + } + if (!current) { + throw new Error( + "Cannot merge policy preset: the normalized current policy could not be parsed. " + + "Re-read the base policy and try again; no policy changes were made.", + ); } // Structured merge: preset entries override existing on name collision. @@ -566,26 +532,39 @@ function removePresetFromPolicy( currentPolicy: string, presetEntries: string | null | undefined, ): string { - const normalizedCurrentPolicy = parseCurrentPolicy(currentPolicy); + const parsedCurrentPolicy = parseCurrentPolicyOrEmpty(currentPolicy); + if (currentPolicy.trim() && !parsedCurrentPolicy) { + throw new Error( + "Cannot remove policy preset: the current policy is not a valid YAML mapping. " + + "Re-read the base policy and try again; no policy changes were made.", + ); + } + const normalizedCurrentPolicy = stripProviderComposedPolicies(parsedCurrentPolicy); if (!presetEntries) { return normalizedCurrentPolicy || "version: 1\n\nnetwork_policies:\n"; } - if (!normalizedCurrentPolicy) return "version: 1\n\nnetwork_policies:\n"; - // Parse preset entries to extract the network_policies key names. // They come as indented content under network_policies:, // so we wrap them to make valid YAML for parsing. - let presetKeys: string[]; + let presetPolicies: PolicyObject; try { const wrapped = "network_policies:\n" + presetEntries; const parsed = YAML.parse(wrapped); - presetKeys = parsed?.network_policies ? Object.keys(parsed.network_policies) : []; + if (!isPolicyDocument(parsed) || !isPresetPolicyMap(parsed.network_policies)) { + throw new Error("network_policies must be a non-empty mapping of policy objects"); + } + presetPolicies = parsed.network_policies; } catch { - presetKeys = []; + throw new Error( + "Cannot remove policy preset: preset network_policies entries must be a valid YAML mapping. " + + "Check the preset file and try again; no policy changes were made.", + ); } + const presetKeys = Object.keys(presetPolicies); if (presetKeys.length === 0) return normalizedCurrentPolicy; + if (!normalizedCurrentPolicy) return "version: 1\n\nnetwork_policies:\n"; // Parse the current policy as structured YAML let current: PolicyDocument | null; @@ -593,10 +572,15 @@ function removePresetFromPolicy( const parsed = YAML.parse(normalizedCurrentPolicy); current = isPolicyDocument(parsed) ? parsed : null; } catch { - return normalizedCurrentPolicy; + current = null; } - if (!current) return normalizedCurrentPolicy; + if (!current) { + throw new Error( + "Cannot remove policy preset: the normalized current policy could not be parsed. " + + "Re-read the base policy and try again; no policy changes were made.", + ); + } // Guard: network_policies may be an array in legacy policies — only // delete keys when it is a plain object. @@ -660,12 +644,13 @@ function removePreset(sandboxName: string, presetName: string): boolean { // Get current policy YAML from sandbox let rawPolicy = ""; try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); + // Mutations start from round-trippable --base, never provider-composed --full. + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); } catch { /* ignored */ } - const currentPolicy = parseCurrentPolicy(rawPolicy); + const currentPolicy = parseCurrentPolicyOrEmpty(rawPolicy); if (!currentPolicy) { console.error(` Could not read current policy for sandbox '${sandboxName}'.`); return false; @@ -820,15 +805,18 @@ function applyPresetContent( } // Get current policy YAML from sandbox - let rawPolicy = ""; + let rawPolicy: string | null = null; try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); + // Mutations start from round-trippable --base, never provider-composed --full. + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); } catch { - /* ignored */ + /* Refused below. */ } - const currentPolicy = parseCurrentPolicy(rawPolicy); - if (rawPolicy.trim() && !currentPolicy) { + const currentPolicy = parseCurrentPolicyOrEmpty(rawPolicy); + // A live mutation requires a usable policy; empty is an invalid read, not a + // fresh sandbox whose unknown policy may be replaced with a scaffold. + if (!currentPolicy) { console.error( ` Could not read the current policy for sandbox '${sandboxName}'; refusing to apply '${presetName}' to avoid overwriting it.`, ); @@ -938,15 +926,18 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { const uniquePresetNames = [...new Set(presetNames)].filter(Boolean); if (uniquePresetNames.length === 0) return true; - let rawPolicy = ""; + let rawPolicy: string | null = null; try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); + // Mutations start from round-trippable --base, never provider-composed --full. + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); } catch { - /* ignored */ + /* Refused below. */ } - let merged = parseCurrentPolicy(rawPolicy); - if (rawPolicy.trim() && !merged) { + let merged = parseCurrentPolicyOrEmpty(rawPolicy); + // Keep the batch entrypoint on the same fail-closed source boundary as + // applyPresetContent: an unusable successful read is still a failed read. + if (!merged) { console.error( ` Could not read the current policy for sandbox '${sandboxName}'; refusing to apply presets to avoid overwriting it.`, ); @@ -1096,6 +1087,12 @@ function loadPresetFromFile(filePath: string): { presetName: string; content: st presetMeta && typeof presetMeta === "object" && !Array.isArray(presetMeta) ? (presetMeta as PolicyObject).name : undefined; + if (typeof presetName === "string" && presetName.startsWith("_provider_")) { + console.error( + ` Preset name cannot start with '_provider_' (reserved by OpenShell): ${filePath}`, + ); + return null; + } if (typeof presetName !== "string" || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(presetName)) { console.error( ` Preset must declare preset.name (lowercase, hyphenated RFC 1123 label): ${filePath}`, @@ -1110,6 +1107,12 @@ function loadPresetFromFile(filePath: string): { presetName: string; content: st console.error(` Preset missing network_policies section: ${filePath}`); return null; } + if (Object.keys(parsed.network_policies).some((name) => name.startsWith("_provider_"))) { + console.error( + ` Preset network_policies keys cannot start with '_provider_' (reserved by OpenShell): ${filePath}`, + ); + return null; + } const np = parsed.network_policies as PolicyObject; if (networkPoliciesHasAllowedIps(np)) { console.error( @@ -1198,12 +1201,12 @@ function presetMatchesGateway( function getGatewayPresets(sandboxName: string): string[] | null { let rawPolicy = ""; try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { ignoreError: true }); + rawPolicy = runCapture(buildPolicyGetFullCommand(sandboxName), { ignoreError: true }); } catch { return null; } - const currentPolicy = parseCurrentPolicy(rawPolicy); + const currentPolicy = parseCurrentPolicyOrEmpty(rawPolicy); if (!currentPolicy) return null; let parsed; @@ -1355,6 +1358,7 @@ export { applyPresets, assertOpenshellResolvable, buildPolicyGetCommand, + buildPolicyGetFullCommand, buildPolicySetCommand, clampSetupPolicyPresetNames, extractPresetEntries, @@ -1373,7 +1377,7 @@ export { networkPoliciesHasAllowedIps, PERMISSIVE_POLICY_PATH, PRESETS_DIR, - parseCurrentPolicy, + parseCurrentPolicyOrEmpty as parseCurrentPolicy, parsePresetPolicyKeys, removePreset, removePresetFromPolicy, diff --git a/src/lib/policy/merge.test.ts b/src/lib/policy/merge.test.ts new file mode 100644 index 00000000000..96c5a09496f --- /dev/null +++ b/src/lib/policy/merge.test.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { stripProviderComposedPolicies, withoutProviderComposedPolicies } from "./merge"; + +describe("OpenShell provider-composed policy boundary", () => { + it("preserves ordinary entries while removing reserved provider entries", () => { + expect( + withoutProviderComposedPolicies({ + safe_entry: { name: "safe-entry" }, + _provider_injected: { name: "must-not-submit" }, + }), + ).toEqual({ safe_entry: { name: "safe-entry" } }); + }); + + it("filters reserved entries through the public YAML mutation boundary", () => { + const filtered = stripProviderComposedPolicies( + [ + "version: 1", + "network_policies:", + " safe_entry:", + " name: safe-entry", + " _provider_injected:", + " name: must-not-submit", + ].join("\n"), + ); + + expect(filtered).toContain("safe_entry:"); + expect(filtered).not.toContain("_provider_injected:"); + }); + + it("fails closed when malformed YAML cannot be filtered", () => { + expect(() => stripProviderComposedPolicies("version: [unterminated")).toThrow( + /Cannot filter provider-composed policy entries from invalid YAML/, + ); + }); +}); diff --git a/src/lib/policy/merge.ts b/src/lib/policy/merge.ts new file mode 100644 index 00000000000..97de8eeb29c --- /dev/null +++ b/src/lib/policy/merge.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + parseOpenShellPolicy as parseCanonicalOpenShellPolicy, + stripProviderComposedPolicies as stripCanonicalProviderComposedPolicies, + withoutProviderComposedPolicies as withoutCanonicalProviderComposedPolicies, +} from "../../../nemoclaw/dist/shared/openshell-policy-boundary.cjs"; + +import type { JsonObject } from "../core/json-types"; + +// sourceOfTruth: nemoclaw/src/shared/openshell-policy-boundary.cts +// generatedBoundary: build:cli emits the canonical .cjs/.d.cts before this +// CommonJS wrapper is compiled. Keep this file implementation-free. +export const parseOpenShellPolicy = parseCanonicalOpenShellPolicy; +export const stripProviderComposedPolicies = stripCanonicalProviderComposedPolicies; + +export function withoutProviderComposedPolicies(policies: JsonObject): JsonObject { + return withoutCanonicalProviderComposedPolicies(policies) as JsonObject; +} diff --git a/src/lib/policy/remove-preset-fail-closed.test.ts b/src/lib/policy/remove-preset-fail-closed.test.ts new file mode 100644 index 00000000000..87c97bc44c9 --- /dev/null +++ b/src/lib/policy/remove-preset-fail-closed.test.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { removePresetFromPolicy } from "./index"; + +describe("removePresetFromPolicy fail-closed boundary", () => { + it("rejects malformed preset YAML without producing a replacement policy", () => { + const currentPolicy = "version: 1\nnetwork_policies:\n pypi: {}\n"; + + expect(() => removePresetFromPolicy(currentPolicy, " pypi: [unterminated")).toThrow( + /Cannot remove policy preset: preset network_policies entries must be a valid YAML mapping/, + ); + }); +}); diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index a3ebaeea005..73c564398e2 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -128,6 +128,11 @@ function stageOptimizedSandboxBuildContext( normalizeReadModesForDockerCopy(stagedBlueprintDir); fs.mkdirSync(stagedScriptsDir, { recursive: true }); + fs.mkdirSync(path.join(stagedScriptsDir, "checks"), { recursive: true }); + fs.copyFileSync( + path.join(rootDir, "scripts", "checks", "verify-openshell-policy-boundary-dependencies.mts"), + path.join(stagedScriptsDir, "checks", "verify-openshell-policy-boundary-dependencies.mts"), + ); fs.copyFileSync( path.join(rootDir, "scripts", "nemoclaw-start.sh"), path.join(stagedScriptsDir, "nemoclaw-start.sh"), diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index 6b80bff4a17..6104d21a808 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -20,7 +20,7 @@ vi.mock("../runner", () => ({ })); vi.mock("../policy", () => ({ - buildPolicyGetCommand: vi.fn((name) => ["openshell", "policy", "get", "--full", name]), + buildPolicyGetCommand: vi.fn((name) => ["openshell", "policy", "get", "--base", name]), buildPolicySetCommand: vi.fn((file, name) => [ "openshell", "policy", diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 4e1f5d32cac..795c72706e6 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -2519,9 +2519,7 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = console.log(" Capturing current policy snapshot..."); let rawPolicy: string; try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { - ignoreError: true, - }); + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); } catch { rawPolicy = ""; } diff --git a/src/lib/shields/policy-transition.test.ts b/src/lib/shields/policy-transition.test.ts new file mode 100644 index 00000000000..d963bc8386e --- /dev/null +++ b/src/lib/shields/policy-transition.test.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +const requireSource = createRequire(import.meta.url); +const SHIELDS_MODULE = "./index.js"; +const TRANSITION_LOCK_MODULE = "./transition-lock.js"; + +describe("shields policy transition", () => { + let homeDir: string; + let runSpy: MockInstance; + let runCaptureSpy: MockInstance; + let shields: typeof import("./index.js"); + + beforeEach(() => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-shields-policy-transition-")); + vi.stubEnv("HOME", homeDir); + delete require.cache[requireSource.resolve(SHIELDS_MODULE)]; + delete require.cache[requireSource.resolve(TRANSITION_LOCK_MODULE)]; + + const runner = requireSource("../runner.js"); + const sandboxConfig = requireSource("../sandbox/config.js"); + vi.spyOn(runner, "validateName").mockImplementation((name: unknown) => String(name)); + runSpy = vi.spyOn(runner, "run").mockReturnValue({ status: 0 }); + runCaptureSpy = vi.spyOn(runner, "runCapture").mockImplementation(() => { + throw new Error("policy get failed with status 42"); + }); + vi.spyOn(sandboxConfig, "resolveAgentConfig").mockReturnValue({ + agentName: "langchain-deepagents-code", + configDir: "/sandbox/.deepagents", + configFile: "config.json", + configPath: "/sandbox/.deepagents/config.json", + format: "json", + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "log").mockImplementation(() => undefined); + shields = requireSource(SHIELDS_MODULE); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + delete require.cache[requireSource.resolve(SHIELDS_MODULE)]; + delete require.cache[requireSource.resolve(TRANSITION_LOCK_MODULE)]; + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it("never relaxes policy or persists mutable state when the base-policy read fails", () => { + expect(() => shields.shieldsDown("openclaw", { skipTimer: true, throwOnError: true })).toThrow( + "Cannot capture current policy", + ); + expect(runSpy).not.toHaveBeenCalled(); + + const stateFiles = fs.readdirSync(path.join(homeDir, ".nemoclaw", "state")); + expect(stateFiles.filter((name) => /^(policy-snapshot-|shields-openclaw)/.test(name))).toEqual( + [], + ); + }); + + it.each([ + ["message", "message: gateway unavailable"], + ["details", "details: grpc unavailable"], + ["arbitrary diagnostic", "reason: gateway unavailable\nretryable: true"], + ])("never relaxes policy or persists mutable state for exit-zero %s output", (_name, output) => { + runCaptureSpy.mockReturnValue(output); + + expect(() => shields.shieldsDown("openclaw", { skipTimer: true, throwOnError: true })).toThrow( + "Cannot capture current policy", + ); + expect(runSpy).not.toHaveBeenCalled(); + + const stateFiles = fs.readdirSync(path.join(homeDir, ".nemoclaw", "state")); + expect(stateFiles.filter((name) => /^(policy-snapshot-|shields-openclaw)/.test(name))).toEqual( + [], + ); + }); +}); diff --git a/test/brev-launchable-ci-cpu-checksum.test.ts b/test/brev-launchable-ci-cpu-checksum.test.ts index 1ad067f83c1..c4d13cbc543 100644 --- a/test/brev-launchable-ci-cpu-checksum.test.ts +++ b/test/brev-launchable-ci-cpu-checksum.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest"; const SCRIPT = path.join(import.meta.dirname, "..", "scripts", "brev-launchable-ci-cpu.sh"); const ASSET = "openshell-x86_64-unknown-linux-musl.tar.gz"; -const PINNED_ASSET_SHA256 = "b71e3a7fb6973c7c353521f88740885e6e661a199b6355140d45f4f8ab72d716"; +const PINNED_ASSET_SHA256 = "37836c3b50383e03249c5e16512c1806e591fba8451408a84fb2f628ddb318c4"; type FakeSystemOptions = { checksum: "match" | "mismatch" | "unpinned"; @@ -175,7 +175,7 @@ done case "$(basename "$out")" in ${ASSET}) tmp="$(mktemp -d)" - printf '#!/usr/bin/env bash\\nprintf "openshell 0.0.71\\\\n"\\n' > "$tmp/openshell" + printf '#!/usr/bin/env bash\\nprintf "openshell 0.0.72\\\\n"\\n' > "$tmp/openshell" chmod +x "$tmp/openshell" /usr/bin/tar -czf "$out" -C "$tmp" openshell rm -rf "$tmp" @@ -234,7 +234,7 @@ function runLaunchable(options: FakeSystemOptions) { ...process.env, LAUNCH_LOG: fake.launchLog, NEMOCLAW_CLONE_DIR: fake.cloneDir, - OPENSHELL_VERSION: options.openshellVersion ?? "v0.0.71", + OPENSHELL_VERSION: options.openshellVersion ?? "v0.0.72", PATH: options.nodeSourceChecksumTool === false ? fake.fakeBin : `${fake.fakeBin}:/usr/bin:/bin`, SUDO_USER: "tester", @@ -256,7 +256,7 @@ describe("brev-launchable-ci-cpu.sh OpenShell checksum gate", { timeout: 30_000 it("rejects malformed OPENSHELL_VERSION before downloads or privileged setup", () => { const { fake, result } = runLaunchable({ checksum: "match", - openshellVersion: "v0.0.71;touch /tmp/nemoclaw-version-injection", + openshellVersion: "v0.0.72;touch /tmp/nemoclaw-version-injection", }); try { const out = combinedLaunchableOutput(result, fake.launchLog); @@ -293,7 +293,7 @@ describe("brev-launchable-ci-cpu.sh OpenShell checksum gate", { timeout: 30_000 const out = combinedLaunchableOutput(result, fake.launchLog); expect(result.status, out).toBe(1); expect(out).toContain( - `OpenShell release checksum for ${ASSET} does not match NemoClaw-pinned v0.0.71 digest`, + `OpenShell release checksum for ${ASSET} does not match NemoClaw-pinned v0.0.72 digest`, ); expect(fs.existsSync(fake.tarLog) ? fs.readFileSync(fake.tarLog, "utf-8") : "").toBe(""); expect(fs.existsSync(fake.sudoLog) ? fs.readFileSync(fake.sudoLog, "utf-8") : "").not.toMatch( @@ -329,7 +329,7 @@ describe("brev-launchable-ci-cpu.sh OpenShell checksum gate", { timeout: 30_000 try { const out = combinedLaunchableOutput(result, fake.launchLog); expect(result.status, out).toBe(0); - expect(out).toContain("OpenShell CLI installed: openshell 0.0.71"); + expect(out).toContain("OpenShell CLI installed: openshell 0.0.72"); expect(fs.readFileSync(fake.tarLog, "utf-8")).toContain(`xzf`); const sudoLog = fs.readFileSync(fake.sudoLog, "utf-8"); expect(sudoLog).toMatch(/^install -m 755 .*openshell/m); diff --git a/test/e2e-test.sh b/test/e2e-test.sh index 277451af3eb..2d20f22955d 100755 --- a/test/e2e-test.sh +++ b/test/e2e-test.sh @@ -144,40 +144,65 @@ fi info "4b. Verify blueprint runner apply smoke test" # ------------------------------------------------------- # Apply runs the full codepath (profile resolution, sandbox creation, -# provider setup, state save) even without openshell — subprocess calls -# use reject:false so they complete silently. We verify the entire -# apply pipeline executes and persists run state to disk. -NEMOCLAW_BLUEPRINT_PATH=/opt/nemoclaw-blueprint node --input-type=module -e " +# provider setup, state save) against a fixture CLI. Policy mutation reads must +# return the same metadata + YAML shape as OpenShell 0.0.72; an empty successful +# response is intentionally rejected by the runner. +FAKE_OPENSHELL_BIN=$(mktemp -d) +APPLY_OUTPUT=$(mktemp) +cleanup_apply_fixture() { + rm -rf "$FAKE_OPENSHELL_BIN" + rm -f "$APPLY_OUTPUT" +} +trap cleanup_apply_fixture EXIT +cat >"$FAKE_OPENSHELL_BIN/openshell" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-} ${2:-} ${3:-}" in + "policy get --base") + printf '%s\n' 'Policy for sandbox fixture' '---' + cat /opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml + ;; + "policy get "*) + echo "unexpected policy read: expected policy get --base" >&2 + exit 64 + ;; +esac +SH +chmod 0755 "$FAKE_OPENSHELL_BIN/openshell" +PATH="$FAKE_OPENSHELL_BIN:$PATH" NEMOCLAW_BLUEPRINT_PATH=/opt/nemoclaw-blueprint node --input-type=module -e " const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); await main(['apply', '--profile', 'ncp']); -" 2>&1 | tee /tmp/apply-output.txt -if grep -q "RUN_ID:" /tmp/apply-output.txt; then +" 2>&1 | tee "$APPLY_OUTPUT" +rm -rf "$FAKE_OPENSHELL_BIN" +if grep -q "RUN_ID:" "$APPLY_OUTPUT"; then pass "Apply generates run ID" else fail "No run ID in apply output" fi -if grep -q "PROGRESS:20:Creating OpenClaw sandbox" /tmp/apply-output.txt; then +if grep -q "PROGRESS:20:Creating OpenClaw sandbox" "$APPLY_OUTPUT"; then pass "Apply executes sandbox creation step" else fail "Apply did not reach sandbox creation step" fi -if grep -q "PROGRESS:50:Configuring inference provider" /tmp/apply-output.txt; then +if grep -q "PROGRESS:50:Configuring inference provider" "$APPLY_OUTPUT"; then pass "Apply executes provider configuration" else fail "Apply did not reach provider configuration step" fi -if grep -q "PROGRESS:100:Apply complete" /tmp/apply-output.txt; then +if grep -q "PROGRESS:100:Apply complete" "$APPLY_OUTPUT"; then pass "Apply completes full pipeline" else fail "Apply did not complete" fi # Verify run state was persisted to disk -RUN_ID=$(grep -o 'nc-[0-9]*-[0-9]*-[a-f0-9]*' /tmp/apply-output.txt | head -1) +RUN_ID=$(grep -o 'nc-[0-9]*-[0-9]*-[a-f0-9]*' "$APPLY_OUTPUT" | head -1) if [ -f "$HOME/.nemoclaw/state/runs/$RUN_ID/plan.json" ]; then pass "Apply persisted run state to disk" else fail "Apply did not persist run state (plan.json missing for $RUN_ID)" fi +rm -f "$APPLY_OUTPUT" +trap - EXIT # ------------------------------------------------------- info "5. Verify host OpenClaw detection (migration source)" diff --git a/test/e2e/live/network-policy-inference.ts b/test/e2e/live/network-policy-inference.ts new file mode 100644 index 00000000000..6865a43ab2b --- /dev/null +++ b/test/e2e/live/network-policy-inference.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +type ChatCompletionChoice = { + message?: { + content?: unknown; + reasoning?: unknown; + reasoning_content?: unknown; + }; + text?: unknown; +}; + +function nonEmptyText(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +/** + * Require an OpenAI-compatible completion body that proves inference.local + * reached a model. Reasoning models can exhaust a small output budget before + * emitting final content, so reasoning-only completions remain valid for this + * connectivity check. + */ +export function requireInferenceLocalCompletionText(raw: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("inference.local response was not valid JSON"); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("inference.local response was not an object"); + } + + const choices = (parsed as { choices?: unknown }).choices; + if (!Array.isArray(choices) || choices.length === 0) { + throw new Error("inference.local response did not contain a completion choice"); + } + + for (const candidate of choices) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; + const choice = candidate as ChatCompletionChoice; + const message = choice.message; + if (message && typeof message === "object") { + for (const value of [message.content, message.reasoning_content, message.reasoning]) { + const completionText = nonEmptyText(value); + if (completionText) return completionText; + } + } + const legacyText = nonEmptyText(choice.text); + if (legacyText) return legacyText; + } + + throw new Error("inference.local response did not contain non-empty content or reasoning text"); +} diff --git a/test/e2e/live/network-policy.test.ts b/test/e2e/live/network-policy.test.ts index d59141970db..0daf9b2fb57 100644 --- a/test/e2e/live/network-policy.test.ts +++ b/test/e2e/live/network-policy.test.ts @@ -22,6 +22,7 @@ import { expect, test } from "../fixtures/e2e-test.ts"; import { shouldRunLiveE2E } from "../fixtures/live-project-gate.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { pollDeniedReasonLog } from "./network-policy-denied-log.ts"; +import { requireInferenceLocalCompletionText } from "./network-policy-inference.ts"; import { POLICY_ADD_EXPECT_SCRIPT, requirePolicyPresetNumber, @@ -431,7 +432,7 @@ RUN_NETWORK_POLICY_TEST( boundary: "live-sandbox-network-policy", contracts: [ "deny-by-default egress", - "OpenShell 0.0.71 preserves the full denied endpoint and policy disposition through nemoclaw logs --tail 50 (#4760)", + "OpenShell 0.0.72 preserves the full denied endpoint and policy disposition through nemoclaw logs --tail 50 (#4760)", "read-only preset allowlist behavior", "weather preset allows wttr.in GET and HEAD but denies POST and unrelated hosts", "live policy-add and dry-run behavior", @@ -467,7 +468,7 @@ RUN_NETWORK_POLICY_TEST( timeoutMs: 30_000, }); expect(openshellVersion.exitCode, text(openshellVersion)).toBe(0); - expect(text(openshellVersion)).toContain("0.0.71"); + expect(text(openshellVersion)).toContain("0.0.72"); const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); cleanup.add(`destroy network-policy sandbox ${SANDBOX_NAME}`, async () => { @@ -838,9 +839,8 @@ printf '\n' -d '{"model":"nvidia/nemotron-3-super-120b-a12b","messages":[{"role":"user","content":"Reply with exactly one word: PONG"}],"max_tokens":50}'`, { artifactName: "tc-net-07-inference-local", timeoutMs: 90_000 }, ); - const inferenceContent = JSON.parse(inference.stdout).choices?.[0]?.message?.content; - expect(typeof inferenceContent).toBe("string"); - expect(inferenceContent.trim().length).toBeGreaterThan(0); + expect(inference.exitCode, text(inference)).toBe(0); + expect(requireInferenceLocalCompletionText(inference.stdout).length).toBeGreaterThan(0); const directProvider = await fetchStatus( sandbox, "https://inference-api.nvidia.com/v1/models", 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 b1946dbe203..87ee728058a 100644 --- a/test/e2e/live/openshell-gateway-auth-source-contract-helpers.ts +++ b/test/e2e/live/openshell-gateway-auth-source-contract-helpers.ts @@ -645,7 +645,7 @@ async function runOpenShellGatewayAuthSourceContractScenarioUnchecked({ const version = run(gatewayBin, ["--version"]); expect(version.status, commandOutput(version)).toBe(0); - expect(commandOutput(version)).toContain("0.0.71"); + expect(commandOutput(version)).toContain("0.0.72"); await requireDockerDaemon({ dockerBin, host, skip }); @@ -675,7 +675,8 @@ async function runOpenShellGatewayAuthSourceContractScenarioUnchecked({ OPENSHELL_BIND_ADDRESS: "127.0.0.1", OPENSHELL_DB_URL: `sqlite:${path.join(stateDir, "openshell.db")}`, OPENSHELL_DOCKER_NETWORK_NAME: networkName, - OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "ghcr.io/nvidia/openshell/supervisor:0.0.71", + OPENSHELL_DOCKER_SUPERVISOR_IMAGE: + "ghcr.io/nvidia/openshell/supervisor@sha256:80ed9cda5bf672fefdb9dcd4604b40a8b09c0891b6eb9d03e10227c7e3dfb49d", OPENSHELL_DRIVERS: "docker", OPENSHELL_GRPC_ENDPOINT: `https://127.0.0.1:${port}`, OPENSHELL_LOCAL_TLS_DIR: certBundle.localTlsDir, diff --git a/test/e2e/live/openshell-gateway-auth-source-contract.test.ts b/test/e2e/live/openshell-gateway-auth-source-contract.test.ts index 537ecf0dda0..03223e6da09 100644 --- a/test/e2e/live/openshell-gateway-auth-source-contract.test.ts +++ b/test/e2e/live/openshell-gateway-auth-source-contract.test.ts @@ -9,9 +9,10 @@ const CONTRACT_ENABLED = shouldRunLiveE2E() || process.env.NEMOCLAW_LIVE_OPENSHELL_GATEWAY_AUTH_CONTRACT === "1"; const liveTest = CONTRACT_ENABLED ? test : test.skip; const LIVE_TIMEOUT_MS = 8 * 60_000; +const OPENSHELL_GATEWAY_AUTH_CONTRACT_VERSION = "0.0.72"; liveTest( - "OpenShell 0.0.71 Docker-driver gateway auth uses NemoClaw mTLS plus sandbox JWT", + `OpenShell ${OPENSHELL_GATEWAY_AUTH_CONTRACT_VERSION} Docker-driver gateway auth uses NemoClaw mTLS plus sandbox JWT`, { timeout: LIVE_TIMEOUT_MS }, runOpenShellGatewayAuthSourceContractScenario, ); diff --git a/test/e2e/live/openshell-gateway-upgrade.test.ts b/test/e2e/live/openshell-gateway-upgrade.test.ts index 4605fe599ba..590ed247029 100644 --- a/test/e2e/live/openshell-gateway-upgrade.test.ts +++ b/test/e2e/live/openshell-gateway-upgrade.test.ts @@ -43,7 +43,7 @@ const STATE_DIR = path.join( const PID_FILE = path.join(STATE_DIR, "openshell-gateway.pid"); const OLD_NEMOCLAW_REF = process.env.NEMOCLAW_OLD_NEMOCLAW_REF ?? "v0.0.36"; const OLD_OPENSHELL_VERSION = process.env.NEMOCLAW_OLD_OPENSHELL_VERSION ?? "0.0.36"; -const CURRENT_OPENSHELL_VERSION = process.env.NEMOCLAW_CURRENT_OPENSHELL_VERSION ?? "0.0.71"; +const CURRENT_OPENSHELL_VERSION = process.env.NEMOCLAW_CURRENT_OPENSHELL_VERSION ?? "0.0.72"; const OLD_SANDBOX_BASE_IMAGE_REF = process.env.NEMOCLAW_OLD_SANDBOX_BASE_IMAGE_REF ?? "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:104151ffadc2ff0b6c815e3c95c2783ced61aee0d0f83fc327cc02be9b7e14e6"; diff --git a/test/e2e/live/openshell-version-pin.test.ts b/test/e2e/live/openshell-version-pin.test.ts index 155420c07c4..eae659c9039 100644 --- a/test/e2e/live/openshell-version-pin.test.ts +++ b/test/e2e/live/openshell-version-pin.test.ts @@ -11,8 +11,8 @@ import { expect, test } from "../fixtures/e2e-test.ts"; // #3474). The former bash script is a hermetic installer-script behavioral // test: it runs scripts/install-openshell.sh under a stubbed PATH where the -// already-installed openshell reports a too-new version (0.0.72) and the -// downloaded archives produce a binary that reports the pinned 0.0.71. +// already-installed openshell reports a too-new version (0.0.73) and the +// downloaded archives produce a binary that reports the pinned 0.0.72. // // This is a free-standing live test (per #5049's pattern) — it does not exercise // the registry-driven steady-state probe model. There is no OpenClaw instance, @@ -21,10 +21,73 @@ import { expect, test } from "../fixtures/e2e-test.ts"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); const INSTALL_SCRIPT = path.join(REPO_ROOT, "scripts", "install-openshell.sh"); + +test("openshell-version-pin: selects shipping 0.0.72 between older and too-new releases", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-resolver-")); + const binDir = path.join(tmpDir, "bin"); + fs.mkdirSync(binDir); + writeExecutable( + path.join(binDir, "gh"), + `#!/bin/sh +printf '%s\\n' '${JSON.stringify([ + { tagName: "v0.0.71" }, + { tagName: "v0.0.73" }, + { tagName: "v0.0.72" }, + ])}'`, + ); + + try { + const result = spawnSync( + process.execPath, + [ + "--import", + "tsx", + "-e", + ` +const pin = require(${JSON.stringify(path.join(REPO_ROOT, "src/lib/onboard/openshell-pin.ts"))}); +const version = require(${JSON.stringify(path.join(REPO_ROOT, "src/lib/onboard/openshell-version.ts"))}); +const deps = { + getBlueprintMinOpenshellVersion: () => "0.0.72", + getBlueprintMaxOpenshellVersion: () => "0.0.72", + versionGte: version.versionGte, +}; +const resolution = pin.resolveOpenshellInstallPin(deps); +const replacement = pin.computeOpenshellInstallEnv( + { INSTALLED_OPENSHELL_VERSION: "0.0.71" }, + deps, +); +process.stdout.write(JSON.stringify({ + installed: version.getInstalledOpenshellVersion("openshell 0.0.71"), + resolution, + replacement: replacement.env, +}));`, + ], + { + cwd: REPO_ROOT, + encoding: "utf8", + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` }, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + installed: "0.0.71", + resolution: { kind: "pin", version: "0.0.72", latest: "0.0.73", reason: "max-cap" }, + replacement: { + INSTALLED_OPENSHELL_VERSION: "0.0.71", + NEMOCLAW_OPENSHELL_MIN_VERSION: "0.0.72", + NEMOCLAW_OPENSHELL_MAX_VERSION: "0.0.72", + NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.72", + }, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + const PINNED_OPEN_SHELL_SHA256 = { - cliLinuxX64: "b71e3a7fb6973c7c353521f88740885e6e661a199b6355140d45f4f8ab72d716", - gatewayLinuxX64: "85fe7c9d939cb2d32389182e816ac388ee1c95dbf5dae1c3dcd37d5bd979db7d", - sandboxLinuxX64: "dbf7fffb285e9ffca7ffd439118b7aadd4e5c4df45c73f0fff89fcca9b19c47d", + cliLinuxX64: "37836c3b50383e03249c5e16512c1806e591fba8451408a84fb2f628ddb318c4", + gatewayLinuxX64: "03225fb9388b682af1a5f1614b26b75f828da6031e3ffc1fd920b6fbe5f70877", + sandboxLinuxX64: "811f914b6a6a3a3f4533449ddebebb6422333861a27a5fa848db6cbfdffdd230", }; type GhDownloadMode = "success" | "fail"; @@ -34,7 +97,7 @@ function writeExecutable(target: string, contents: string): void { } // Bash helpers shared by the gh and curl stubs: write a fake archive and emit -// the same pinned digest lines the real OpenShell v0.0.71 release uses. A fake +// the same pinned digest lines the real OpenShell v0.0.72 release uses. A fake // sha256sum below keeps this test hermetic even though the tarball bytes are // synthetic. const SHARED_DOWNLOAD_BASH_HELPERS = `\ @@ -263,11 +326,11 @@ async function runVersionPinTarget( fs.writeFileSync(downloadLog, ""); createFakeUname(fakeBin); - createFakeStickyOpenshell(fakeBin, "0.0.72"); + createFakeStickyOpenshell(fakeBin, "0.0.73"); createFakeHelperBinaries(fakeBin); createFakeGh(fakeBin, downloadLog, options.ghDownloadMode); createFakeCurl(fakeBin, downloadLog); - createFakeTar(fakeBin, "0.0.71"); + createFakeTar(fakeBin, "0.0.72"); createFakeStrings(fakeBin); createFakeSha256sum(fakeBin); @@ -290,40 +353,40 @@ async function runVersionPinTarget( // "above the maximum" hard-fail before download). expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - // Assertion 2: download-log-contains-v0.0.71 — pinned release tag was + // Assertion 2: download-log-contains-v0.0.72 — pinned release tag was // requested from the release host. const downloads = fs.readFileSync(downloadLog, "utf-8"); - expect(downloads).toContain("v0.0.71"); + expect(downloads).toContain("v0.0.72"); - // Assertion 3: download-log-excludes-v0.0.72 — the too-new sticky version + // Assertion 3: download-log-excludes-v0.0.73 — the too-new sticky version // is never re-fetched. - expect(downloads).not.toContain("v0.0.72"); + expect(downloads).not.toContain("v0.0.73"); if (options.ghDownloadMode === "fail") { // Assertion 3b: curl-fallback-observed — the installer must recover from // gh download failure by re-requesting the pinned assets via curl. - expect(downloads).toContain("gh download-fail v0.0.71"); + expect(downloads).toContain("gh download-fail v0.0.72"); expect(downloads).toContain("curl "); } else { - expect(downloads).toContain("gh download v0.0.71"); + expect(downloads).toContain("gh download v0.0.72"); expect(downloads).not.toContain("curl "); } - // Assertion 4: replaced-openshell-reports-0.0.71 — the binary on disk in + // Assertion 4: replaced-openshell-reports-0.0.72 — the binary on disk in // the active install dir (== fakeBin, since ACTIVE_OPENSHELL_BIN resolved - // there and it is writable) was overwritten with the pinned 0.0.71 build. + // there and it is writable) was overwritten with the pinned 0.0.72 build. const replacedVersion = spawnSync(path.join(fakeBin, "openshell"), ["--version"], { encoding: "utf8", }); expect(replacedVersion.status).toBe(0); - expect(replacedVersion.stdout).toContain("0.0.71"); - expect(replacedVersion.stdout).not.toContain("0.0.72"); + expect(replacedVersion.stdout).toContain("0.0.72"); + expect(replacedVersion.stdout).not.toContain("0.0.73"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } } -test("openshell-version-pin: replaces sticky too-new openshell with pinned 0.0.71 via gh download", async ({ +test("openshell-version-pin: replaces sticky too-new openshell with pinned 0.0.72 via gh download", async ({ artifacts, }) => { await runVersionPinTarget(artifacts, { ghDownloadMode: "success" }); diff --git a/test/e2e/support/network-policy-inference.test.ts b/test/e2e/support/network-policy-inference.test.ts new file mode 100644 index 00000000000..46e94655afe --- /dev/null +++ b/test/e2e/support/network-policy-inference.test.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { requireInferenceLocalCompletionText } from "../live/network-policy-inference.ts"; + +describe("network-policy inference.local completion proof", () => { + it("accepts final assistant content", () => { + const raw = JSON.stringify({ choices: [{ message: { content: " PONG " } }] }); + + expect(requireInferenceLocalCompletionText(raw)).toBe("PONG"); + }); + + it("accepts reasoning-only output when final content is null", () => { + const raw = JSON.stringify({ + choices: [ + { + finish_reason: "length", + message: { content: null, reasoning_content: "The requested answer is PONG." }, + }, + ], + }); + + expect(requireInferenceLocalCompletionText(raw)).toBe("The requested answer is PONG."); + }); + + it("rejects a response without completion or reasoning text", () => { + const raw = JSON.stringify({ choices: [{ message: { content: null } }] }); + + expect(() => requireInferenceLocalCompletionText(raw)).toThrow( + "inference.local response did not contain non-empty content or reasoning text", + ); + }); + + it("rejects a non-JSON response", () => { + expect(() => requireInferenceLocalCompletionText("upstream unavailable")).toThrow( + "inference.local response was not valid JSON", + ); + }); +}); 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 96fa8606e8e..2cc3dddd7b6 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 @@ -74,7 +74,7 @@ describe("OpenShell gateway auth contract workflow boundary", () => { E2E_ARTIFACT_DIR: "${{ github.workspace }}/e2e-artifacts/live/openshell-gateway-auth-contract", NEMOCLAW_RUN_LIVE_E2E: "1", - NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.71", + NEMOCLAW_OPENSHELL_PIN_VERSION: "0.0.72", DOCKER_GRPC_PROBE_IMAGE: "node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d", }); diff --git a/test/install-openshell-version-check.test.ts b/test/install-openshell-version-check.test.ts index 82b44a46b54..323dc923f54 100644 --- a/test/install-openshell-version-check.test.ts +++ b/test/install-openshell-version-check.test.ts @@ -9,11 +9,14 @@ import { describe, expect, it } from "vitest"; const SCRIPT = path.join(import.meta.dirname, "..", "scripts", "install-openshell.sh"); const PINNED_OPEN_SHELL_SHA256 = { - cliDarwinArm64: "1ef9a2b447a35391a6a0f417f4383d99f3e928e443cf86ed190002ec937a8871", - cliLinuxX64: "b71e3a7fb6973c7c353521f88740885e6e661a199b6355140d45f4f8ab72d716", - gatewayDarwinArm64: "26fa5b4dcb6d2631f7212639d087f37d8b0fc50c6f6cec856e019c22847e5bc9", - gatewayLinuxX64: "85fe7c9d939cb2d32389182e816ac388ee1c95dbf5dae1c3dcd37d5bd979db7d", - sandboxLinuxX64: "dbf7fffb285e9ffca7ffd439118b7aadd4e5c4df45c73f0fff89fcca9b19c47d", + cliDarwinArm64: "117b5354cc42d80bc4d5e070ea5ac4e341208ff6d3c29b516d8a9c80e2310f8d", + cliLinuxArm64: "a5ff01a3240d73c72ec1700eda6cc6c752a86cf50c5dd1b5bdc459f544d03045", + cliLinuxX64: "37836c3b50383e03249c5e16512c1806e591fba8451408a84fb2f628ddb318c4", + gatewayDarwinArm64: "8c07362107393eb5f4ae4b9ee9f4257fd53862c51ad8dd96f2fe31bb6d8d7ffb", + gatewayLinuxArm64: "a97dcb3acb04fb2d1170c1a2170228990c2337e25bb8c18817e5a6e952204108", + gatewayLinuxX64: "03225fb9388b682af1a5f1614b26b75f828da6031e3ffc1fd920b6fbe5f70877", + sandboxLinuxArm64: "2cf62cbd651e55d0f8750804e2b4025e0d6c8eea4564c87cda47a2c922941db0", + sandboxLinuxX64: "811f914b6a6a3a3f4533449ddebebb6422333861a27a5fa848db6cbfdffdd230", }; const ZERO_SHA256 = "0000000000000000000000000000000000000000000000000000000000000000"; @@ -132,29 +135,29 @@ exit 0`, } describe("install-openshell.sh version check", { timeout: 15_000 }, () => { - it("exits cleanly when openshell 0.0.71 and driver binaries are already installed", () => { - const result = runWithInstalledVersion("0.0.71"); + it("exits cleanly when openshell 0.0.72 and driver binaries are already installed", () => { + const result = runWithInstalledVersion("0.0.72"); expect(result.status).toBe(0); - expect(result.stdout).toMatch(/already installed.*0\.0\.71/); + expect(result.stdout).toMatch(/already installed.*0\.0\.72/); }); - it("triggers reinstall when openshell 0.0.71 is missing Docker-driver binaries", () => { - const result = runWithInstalledVersion("0.0.71", {}, { driverBins: false, os: "Linux" }); + it("triggers reinstall when openshell 0.0.72 is missing Docker-driver binaries", () => { + const result = runWithInstalledVersion("0.0.72", {}, { driverBins: false, os: "Linux" }); expect(result.status).not.toBe(0); expect(result.stdout).toMatch(/missing Docker-driver binaries/); - expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.71'/); + expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.72'/); }); - it("fails closed when openshell 0.0.71 lacks required messaging rewrite support", () => { - const result = runWithInstalledVersion("0.0.71", {}, { capability: false }); + it("fails closed when openshell 0.0.72 lacks required messaging rewrite support", () => { + const result = runWithInstalledVersion("0.0.72", {}, { capability: false }); expect(result.status).toBe(1); // `fail()` writes to stderr as of #3446; previously stdout. expect(result.stderr).toMatch(/missing request-body-credential-rewrite support/); }); - it("accepts macOS openshell 0.0.71 when the gateway binary is installed", () => { + it("accepts macOS openshell 0.0.72 when the gateway binary is installed", () => { const result = runWithInstalledVersion( - "0.0.71", + "0.0.72", {}, { driverBins: "gateway", @@ -163,7 +166,7 @@ describe("install-openshell.sh version check", { timeout: 15_000 }, () => { }, ); expect(result.status).toBe(0); - expect(result.stdout).toMatch(/already installed.*0\.0\.71/); + expect(result.stdout).toMatch(/already installed.*0\.0\.72/); }); it("does not require the macOS VM driver entitlement for Docker-driver onboarding", () => { @@ -172,7 +175,7 @@ describe("install-openshell.sh version check", { timeout: 15_000 }, () => { const state = path.join(tmp, "codesign-state"); const log = path.join(tmp, "codesign.log"); const result = runWithInstalledVersion( - "0.0.71", + "0.0.72", { NEMOCLAW_FAKE_CODESIGN_HAS_ENTITLEMENT: "0", NEMOCLAW_FAKE_CODESIGN_STATE: state, @@ -186,7 +189,7 @@ describe("install-openshell.sh version check", { timeout: 15_000 }, () => { ); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toMatch(/already installed.*0\.0\.71/); + expect(result.stdout).toMatch(/already installed.*0\.0\.72/); expect(result.stdout).not.toMatch(/missing the macOS Hypervisor entitlement/); expect(result.stdout).not.toMatch(/Signing openshell-driver-vm/); expect(result.stdout).not.toMatch(/Installing OpenShell from release/); @@ -196,9 +199,9 @@ describe("install-openshell.sh version check", { timeout: 15_000 }, () => { } }); - it("triggers reinstall on macOS when openshell 0.0.71 is missing required gateway binaries", () => { + it("triggers reinstall on macOS when openshell 0.0.72 is missing required gateway binaries", () => { const result = runWithInstalledVersion( - "0.0.71", + "0.0.72", {}, { driverBins: false, @@ -208,7 +211,7 @@ describe("install-openshell.sh version check", { timeout: 15_000 }, () => { ); expect(result.status).not.toBe(0); expect(result.stdout).toMatch(/missing Docker-driver binaries/); - expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.71'/); + expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.72'/); }); it("downloads the macOS arm64 gateway asset during reinstall", () => { @@ -282,7 +285,7 @@ dest="\${@: -1}" mkdir -p "$(dirname "$dest")" cat > "$dest" <<'EOF' #!/usr/bin/env bash -if [ "\${1:-}" = "--version" ]; then echo "openshell 0.0.71"; exit 0; fi +if [ "\${1:-}" = "--version" ]; then echo "openshell 0.0.72"; exit 0; fi # request-body-credential-rewrite websocket-credential-rewrite exit 0 EOF @@ -312,6 +315,116 @@ exit 0`, } }); + it("downloads and verifies every Linux arm64 release asset during reinstall", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-linux-arm64-assets-")); + try { + const fakeBin = path.join(tmp, "bin"); + const downloadLog = path.join(tmp, "downloads.log"); + const checksumLog = path.join(tmp, "checksums.log"); + fs.mkdirSync(fakeBin); + + writeExecutable( + path.join(fakeBin, "uname"), + `#!/usr/bin/env bash +if [ "\${1:-}" = "-m" ]; then echo "aarch64"; else echo "Linux"; fi`, + ); + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +if [ "\${1:-}" = "--version" ]; then echo "openshell 0.0.36"; exit 0; fi +exit 99`, + ); + writeExecutable(path.join(fakeBin, "gh"), "#!/usr/bin/env bash\nexit 1\n"); + writeExecutable( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +echo "$@" >> ${JSON.stringify(downloadLog)} +out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then shift; out="$1"; fi + shift || true +done +case "$(basename "$out")" in +openshell-checksums-sha256.txt) + printf '%s\n' '${PINNED_OPEN_SHELL_SHA256.cliLinuxArm64} openshell-aarch64-unknown-linux-musl.tar.gz' > "$out" ;; +openshell-gateway-checksums-sha256.txt) + printf '%s\n' '${PINNED_OPEN_SHELL_SHA256.gatewayLinuxArm64} openshell-gateway-aarch64-unknown-linux-gnu.tar.gz' > "$out" ;; +openshell-sandbox-checksums-sha256.txt) + printf '%s\n' '${PINNED_OPEN_SHELL_SHA256.sandboxLinuxArm64} openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz' > "$out" ;; +*) : > "$out" ;; +esac +exit 0`, + ); + writeExecutable( + path.join(fakeBin, "sha256sum"), + `#!/usr/bin/env bash +[ "$#" -eq 2 ] && [ "$1" = "-c" ] && [ "$2" = "-" ] || exit 9 +line="$(cat)" +case "$line" in +'${PINNED_OPEN_SHELL_SHA256.cliLinuxArm64} openshell-aarch64-unknown-linux-musl.tar.gz'|\ +'${PINNED_OPEN_SHELL_SHA256.gatewayLinuxArm64} openshell-gateway-aarch64-unknown-linux-gnu.tar.gz'|\ +'${PINNED_OPEN_SHELL_SHA256.sandboxLinuxArm64} openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz') ;; +*) exit 10 ;; +esac +printf '%s\n' "$line" >> ${JSON.stringify(checksumLog)} +printf '%s\n' 'checksum OK'`, + ); + writeExecutable( + path.join(fakeBin, "tar"), + `#!/usr/bin/env bash +outdir="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-C" ]; then outdir="$arg"; break; fi + prev="$arg" +done +case "$*" in +*openshell-gateway*) name="openshell-gateway" ;; +*openshell-sandbox*) name="openshell-sandbox" ;; +*) name="openshell" ;; +esac +printf '#!/usr/bin/env bash\nexit 0\n' > "$outdir/$name" +chmod 755 "$outdir/$name"`, + ); + writeExecutable( + path.join(fakeBin, "install"), + `#!/usr/bin/env bash +dest="\${@: -1}" +mkdir -p "$(dirname "$dest")" +case "$(basename "$dest")" in +openshell) + printf '#!/usr/bin/env bash\nif [ "$1" = "--version" ]; then echo "openshell 0.0.72"; else exit 0; fi\n# request-body-credential-rewrite websocket-credential-rewrite\n' > "$dest" ;; +*) printf '#!/usr/bin/env bash\nexit 0\n' > "$dest" ;; +esac +chmod 755 "$dest"`, + ); + + const result = spawnSync("bash", [SCRIPT], { + env: { + ...process.env, + HOME: tmp, + XDG_BIN_HOME: path.join(tmp, "local-bin"), + NEMOCLAW_OPENSHELL_CHANNEL: "stable", + PATH: `${fakeBin}:/usr/bin:/bin`, + }, + encoding: "utf8", + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + const downloads = fs.readFileSync(downloadLog, "utf8"); + expect(downloads).toContain("openshell-aarch64-unknown-linux-musl.tar.gz"); + expect(downloads).toContain("openshell-gateway-aarch64-unknown-linux-gnu.tar.gz"); + expect(downloads).toContain("openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz"); + expect(fs.readFileSync(checksumLog, "utf8").trim().split("\n")).toEqual([ + `${PINNED_OPEN_SHELL_SHA256.cliLinuxArm64} openshell-aarch64-unknown-linux-musl.tar.gz`, + `${PINNED_OPEN_SHELL_SHA256.gatewayLinuxArm64} openshell-gateway-aarch64-unknown-linux-gnu.tar.gz`, + `${PINNED_OPEN_SHELL_SHA256.sandboxLinuxArm64} openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz`, + ]); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("upgrades into the active writable openshell directory to avoid PATH shadowing", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-active-dir-")); try { @@ -404,7 +517,7 @@ printf '%s\\n' "$dest" >> ${JSON.stringify(installLog)} mkdir -p "$(dirname "$dest")" case "$(basename "$dest")" in openshell) - printf '#!/usr/bin/env bash\\nif [ "$1" = "--version" ]; then echo "openshell 0.0.71"; else exit 0; fi\\n# request-body-credential-rewrite websocket-credential-rewrite\\n' > "$dest" + printf '#!/usr/bin/env bash\\nif [ "$1" = "--version" ]; then echo "openshell 0.0.72"; else exit 0; fi\\n# request-body-credential-rewrite websocket-credential-rewrite\\n' > "$dest" ;; *) printf '#!/usr/bin/env bash\\nexit 0\\n' > "$dest" @@ -521,7 +634,7 @@ exit 0`, expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(1); expect(result.stderr).toContain( - "OpenShell release checksum for openshell-x86_64-unknown-linux-musl.tar.gz does not match NemoClaw-pinned v0.0.71 digest", + "OpenShell release checksum for openshell-x86_64-unknown-linux-musl.tar.gz does not match NemoClaw-pinned v0.0.72 digest", ); expect(fs.existsSync(tarLog) ? fs.readFileSync(tarLog, "utf-8") : "").toBe(""); expect(fs.existsSync(installLog) ? fs.readFileSync(installLog, "utf-8") : "").toBe(""); @@ -556,45 +669,45 @@ exit 0`, }); it("reinstalls the pinned release when openshell is above MAX_VERSION", () => { - const result = runWithInstalledVersion("0.0.72"); + const result = runWithInstalledVersion("0.0.73"); expect(result.status).not.toBe(0); - expect(result.stdout).toMatch(/above the maximum.*reinstalling pinned OpenShell 0\.0\.71/); - expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.71'/); + expect(result.stdout).toMatch(/above the maximum.*reinstalling pinned OpenShell 0\.0\.72/); + expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.72'/); expect(result.stderr).not.toMatch(/Upgrade NemoClaw first/); }); it("reinstalls the pinned release when openshell is at a much newer version", () => { const result = runWithInstalledVersion("0.1.0"); expect(result.status).not.toBe(0); - expect(result.stdout).toMatch(/above the maximum.*reinstalling pinned OpenShell 0\.0\.71/); - expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.71'/); + expect(result.stdout).toMatch(/above the maximum.*reinstalling pinned OpenShell 0\.0\.72/); + expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.72'/); expect(result.stderr).not.toMatch(/Upgrade NemoClaw first/); }); it("accepts an installed OpenShell dev-channel Docker-driver build", () => { - const result = runWithInstalledVersion("0.0.71.dev84+g6b2180425", { + const result = runWithInstalledVersion("0.0.72.dev84+g6b2180425", { NEMOCLAW_OPENSHELL_CHANNEL: "dev", - NEMOCLAW_ALLOW_DEV_NO_VERIFY: "1", + NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL: "1", }); expect(result.status).toBe(0); expect(result.stdout).toMatch(/dev channel/); expect(result.stdout).toMatch(/Dev channel install skips SHA-256 verification/); }); - it("fails closed for dev-channel installs without explicit no-verify opt-in", () => { - const result = runWithInstalledVersion("0.0.71.dev84+g6b2180425", { + it("fails closed for dev-channel installs without explicit risk acceptance", () => { + const result = runWithInstalledVersion("0.0.72.dev84+g6b2180425", { NEMOCLAW_OPENSHELL_CHANNEL: "dev", }); expect(result.status).toBe(1); expect(result.stderr).toContain( - "Set NEMOCLAW_ALLOW_DEV_NO_VERIFY=1 to allow unverified OpenShell dev-channel installs.", + "Set NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL=1 to explicitly accept an unverified OpenShell dev-channel install.", ); }); it("upgrades stable OpenShell when the dev channel is requested", () => { const result = runWithInstalledVersion("0.0.36", { NEMOCLAW_OPENSHELL_CHANNEL: "dev", - NEMOCLAW_ALLOW_DEV_NO_VERIFY: "1", + NEMOCLAW_ACCEPT_DEV_UNVERIFIED_INSTALL: "1", }); expect(result.status).not.toBe(0); expect(result.stdout).toMatch(/required dev-channel messaging-rewrite build/); diff --git a/test/installer-hash-check.test.ts b/test/installer-hash-check.test.ts new file mode 100644 index 00000000000..b46f040f2c0 --- /dev/null +++ b/test/installer-hash-check.test.ts @@ -0,0 +1,497 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const ASSET_DIGESTS = new Map([ + [ + "openshell-x86_64-unknown-linux-musl.tar.gz", + "37836c3b50383e03249c5e16512c1806e591fba8451408a84fb2f628ddb318c4", + ], + [ + "openshell-aarch64-unknown-linux-musl.tar.gz", + "a5ff01a3240d73c72ec1700eda6cc6c752a86cf50c5dd1b5bdc459f544d03045", + ], + [ + "openshell-aarch64-apple-darwin.tar.gz", + "117b5354cc42d80bc4d5e070ea5ac4e341208ff6d3c29b516d8a9c80e2310f8d", + ], + [ + "openshell-gateway-x86_64-unknown-linux-gnu.tar.gz", + "03225fb9388b682af1a5f1614b26b75f828da6031e3ffc1fd920b6fbe5f70877", + ], + [ + "openshell-gateway-aarch64-unknown-linux-gnu.tar.gz", + "a97dcb3acb04fb2d1170c1a2170228990c2337e25bb8c18817e5a6e952204108", + ], + [ + "openshell-gateway-aarch64-apple-darwin.tar.gz", + "8c07362107393eb5f4ae4b9ee9f4257fd53862c51ad8dd96f2fe31bb6d8d7ffb", + ], + [ + "openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz", + "811f914b6a6a3a3f4533449ddebebb6422333861a27a5fa848db6cbfdffdd230", + ], + [ + "openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz", + "2cf62cbd651e55d0f8750804e2b4025e0d6c8eea4564c87cda47a2c922941db0", + ], +]); +const ASSETS = [...ASSET_DIGESTS.keys()]; +const UNPUBLISHED_ASSET = "openshell-sandbox-aarch64-unknown-linux-gnu-unpublished.tar.gz"; +const SYMLINK_INPUT_MARKER = "LEAK565"; +type FixtureMode = + | "brev-mismatch" + | "complete" + | "duplicate-brev-pin" + | "failure" + | "missing-brev-pin" + | "non-regular-brev-input" + | "oversized-installer-input" + | "partial" + | "partial-asset-missing" + | "partial-manifest-missing" + | "pr-checker-bypass" + | "pr-parser-bypass" + | "symlink-installer-input" + | "symlink-scripts-parent"; +type PinFormatting = + | "canonical" + | "comments" + | "equals-whitespace" + | "line-continuations" + | "mixed-whitespace" + | "quote-styles"; + +const corruptFirstBrevPin = (source: string): string => + source.replace(ASSET_DIGESTS.get(ASSETS[0]) ?? "missing", "0".repeat(64)); +const BREV_MUTATIONS: Partial string>> = { + "brev-mismatch": corruptFirstBrevPin, + "duplicate-brev-pin": (source) => { + const pinLine = ` printf '%s\\n' "${ASSET_DIGESTS.get(ASSETS[0])}"`; + return source.replace(pinLine, `${pinLine}\n${pinLine}`); + }, + "missing-brev-pin": (source) => + source.replace(ASSET_DIGESTS.get(ASSETS[1]) ?? "missing", "missing"), + "pr-checker-bypass": corruptFirstBrevPin, + "pr-parser-bypass": corruptFirstBrevPin, +}; +const INSTALLER_MUTATIONS: Partial string>> = { + "partial-asset-missing": (source) => + source.replace(ASSETS.at(-1) ?? "missing", UNPUBLISHED_ASSET), +}; +type InputMutationContext = { + brevInstaller: string; + fixtureRoot: string; + installer: string; +}; +const INPUT_MUTATIONS: Partial void>> = { + "non-regular-brev-input": ({ brevInstaller }) => { + fs.rmSync(brevInstaller); + fs.mkdirSync(brevInstaller); + }, + "oversized-installer-input": ({ installer }) => { + fs.appendFileSync(installer, `\n# ${"x".repeat(1024 * 1024)}\n`); + }, + "symlink-installer-input": ({ fixtureRoot, installer }) => { + const symlinkTarget = path.join(fixtureRoot, "valid-installer-target.sh"); + fs.renameSync(installer, symlinkTarget); + fs.writeFileSync(symlinkTarget, `""\n${SYMLINK_INPUT_MARKER}\n`); + fs.symlinkSync(symlinkTarget, installer); + }, + "symlink-scripts-parent": ({ fixtureRoot }) => { + const candidateScriptsDir = path.join(fixtureRoot, "scripts"); + const scriptsTarget = path.join(fixtureRoot, "candidate-scripts-target"); + fs.renameSync(candidateScriptsDir, scriptsTarget); + fs.writeFileSync( + path.join(scriptsTarget, "install-openshell.sh"), + `""\n${SYMLINK_INPUT_MARKER}\n`, + ); + fs.symlinkSync(scriptsTarget, candidateScriptsDir, "dir"); + }, +}; +const CHECKSUM_MANIFESTS = new Map([ + [ + "openshell-checksums-sha256.txt", + `37836c3b50383e03249c5e16512c1806e591fba8451408a84fb2f628ddb318c4 openshell-x86_64-unknown-linux-musl.tar.gz +a5ff01a3240d73c72ec1700eda6cc6c752a86cf50c5dd1b5bdc459f544d03045 openshell-aarch64-unknown-linux-musl.tar.gz +117b5354cc42d80bc4d5e070ea5ac4e341208ff6d3c29b516d8a9c80e2310f8d openshell-aarch64-apple-darwin.tar.gz +911dd804074c620b3ba353f17e39a8195222c0764072621a154164432d7906d0 openshell-driver-vm-x86_64-unknown-linux-gnu.tar.gz +5e6ba04030938e7be21b8b83af9a34b888deffb4c65e7e70dd6845c3bc7e264f openshell-driver-vm-aarch64-unknown-linux-gnu.tar.gz +cdcdf0d0b5a231c0c7631787de014462093ffdeb5c85de853594fd215b0fa98a openshell-driver-vm-aarch64-apple-darwin.tar.gz +f4807cdaf3598c1fbcd0f35c888bf7f42210e1f4ab27700a1200d5bf80e56e9a openshell_0.0.72-1_amd64.deb +e38eca3badbba827c7342e2d738b277c8714081a54700ce4dc6c5395e1608d6b openshell_0.0.72-1_arm64.deb +626aa3c781027231a2085ebbdb5a4e2ae88c1c0977bfb1fd7ddaab501efe37c5 openshell-0.0.72-1.fc44.aarch64.rpm +abca83026aa8192a82c54316e6f15f38583fdd59d936535d07fe7bb5e6824a32 openshell-0.0.72-1.fc44.x86_64.rpm +cf349d3cd5fb5f05419ee088a4784206ce117af07f427e0667290955659c7530 openshell-gateway-0.0.72-1.fc44.aarch64.rpm +523087b888d6641a1798c3400492028d5c236870f321ab87d28918e3ae523c20 openshell-gateway-0.0.72-1.fc44.x86_64.rpm +fc590490e1a89c00b8f95b5449de9107cb9f070bd4a8cefb0f2389baf0d95f67 openshell-0.0.72-py3-none-macosx_13_0_arm64.whl +e104152e6840dc2bed10856251ed6b3a020ed5f5550e735a325028a0990b475b openshell-0.0.72-py3-none-manylinux_2_39_aarch64.whl +c7feaca0c8c97ace952bd047408a91732fbcb298517481152d8e53d49c5fc88f openshell-0.0.72-py3-none-manylinux_2_39_x86_64.whl +`, + ], + [ + "openshell-gateway-checksums-sha256.txt", + `03225fb9388b682af1a5f1614b26b75f828da6031e3ffc1fd920b6fbe5f70877 openshell-gateway-x86_64-unknown-linux-gnu.tar.gz +a97dcb3acb04fb2d1170c1a2170228990c2337e25bb8c18817e5a6e952204108 openshell-gateway-aarch64-unknown-linux-gnu.tar.gz +8c07362107393eb5f4ae4b9ee9f4257fd53862c51ad8dd96f2fe31bb6d8d7ffb openshell-gateway-aarch64-apple-darwin.tar.gz +`, + ], + [ + "openshell-sandbox-checksums-sha256.txt", + `811f914b6a6a3a3f4533449ddebebb6422333861a27a5fa848db6cbfdffdd230 openshell-sandbox-x86_64-unknown-linux-gnu.tar.gz +2cf62cbd651e55d0f8750804e2b4025e0d6c8eea4564c87cda47a2c922941db0 openshell-sandbox-aarch64-unknown-linux-gnu.tar.gz +`, + ], +]); +const tempDirs: string[] = []; + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +function renderPinFunction( + functionName: string, + assets: string[], + openshellVersion: string, + formatting: PinFormatting, +): string { + const functionOpening = + formatting === "mixed-whitespace" ? `${functionName}\t( )\t{` : `${functionName}() {`; + const localInputs = + formatting === "equals-whitespace" + ? ' local release_tag = "$1" asset = "$2"' + : formatting === "mixed-whitespace" + ? '\tlocal\trelease_tag="$1"\tasset="$2"' + : ' local release_tag="$1" asset="$2"'; + const caseOpening = + formatting === "mixed-whitespace" + ? '\tcase\t"${release_tag}:${asset}"\tin' + : ' case "${release_tag}:${asset}" in'; + const cases = assets + .map((asset) => { + const digest = ASSET_DIGESTS.get(asset) ?? "missing"; + const pattern = + formatting === "quote-styles" + ? ` 'v${openshellVersion}:${asset}')` + : formatting === "mixed-whitespace" + ? `\t v${openshellVersion}:${asset}\t)` + : ` v${openshellVersion}:${asset})`; + const patternLine = formatting === "comments" ? `${pattern} # exact asset` : pattern; + const printfLine = + formatting === "line-continuations" + ? ` printf \\ + '%s\\n' \\ + "${digest}"` + : formatting === "quote-styles" + ? ` printf "%s\\n" '${digest}'` + : formatting === "mixed-whitespace" + ? `\t\tprintf\t'%s\\n'\t"${digest}"` + : ` printf '%s\\n' "${digest}"`; + const commentedPrintf = + formatting === "comments" ? `${printfLine} # published SHA-256` : printfLine; + const terminator = formatting === "mixed-whitespace" ? "\t\t;;" : " ;;"; + return `${patternLine}\n${commentedPrintf}\n${terminator}`; + }) + .join("\n"); + return `${functionOpening}\n${localInputs}\n${caseOpening}\n${cases}\n *)\n return 1\n ;;\n esac\n}\n`; +} + +function createFixture( + openshellVersion = "0.0.72", + formatting: PinFormatting = "canonical", +): string { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-installer-hash-")); + const scriptsDir = path.join(fixtureRoot, "scripts"); + const checksDir = path.join(scriptsDir, "checks"); + const binDir = path.join(fixtureRoot, "bin"); + tempDirs.push(fixtureRoot); + fs.mkdirSync(checksDir, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + const checker = fs + .readFileSync(path.join(REPO_ROOT, "scripts", "check-installer-hash.sh"), "utf8") + .replace( + 'OPENSHELL_RELEASE_VERSION="0.0.72"', + `OPENSHELL_RELEASE_VERSION="${openshellVersion}"`, + ); + fs.writeFileSync(path.join(scriptsDir, "check-installer-hash.sh"), checker); + fs.copyFileSync( + path.join(REPO_ROOT, "scripts", "checks", "extract-installer-pins.mts"), + path.join(checksDir, "extract-installer-pins.mts"), + ); + + fs.writeFileSync( + path.join(scriptsDir, "install-openshell.sh"), + renderPinFunction("openshell_pinned_sha256", ASSETS, openshellVersion, formatting), + ); + fs.writeFileSync( + path.join(scriptsDir, "brev-launchable-ci-cpu.sh"), + renderPinFunction( + "openshell_cli_pinned_sha256", + ASSETS.slice(0, 2), + openshellVersion, + formatting, + ), + ); + fs.writeFileSync( + path.join(binDir, "curl"), + `#!/usr/bin/env bash +set -euo pipefail +output= +url= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) output="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac +done +case "$url" in + *releases/download/v${openshellVersion}/*) + case "\${NEMOCLAW_TEST_CURL_MODE}" in + failure) exit 22 ;; + esac + case "\${url##*/}" in + openshell-checksums-sha256.txt) + case "\${NEMOCLAW_TEST_CURL_MODE}" in + partial) printf '%s\\n' '${CHECKSUM_MANIFESTS.get("openshell-checksums-sha256.txt")?.split("\n")[0]}' >"$output" ;; + *) printf '%s' '${CHECKSUM_MANIFESTS.get("openshell-checksums-sha256.txt")}' >"$output" ;; + esac + ;; + openshell-gateway-checksums-sha256.txt) + case "\${NEMOCLAW_TEST_CURL_MODE}" in + partial-manifest-missing) + printf '%s\n' 'curl: (22) The requested URL returned error: 404' >&2 + exit 22 + ;; + *) printf '%s' '${CHECKSUM_MANIFESTS.get("openshell-gateway-checksums-sha256.txt")}' >"$output" ;; + esac + ;; + openshell-sandbox-checksums-sha256.txt) + printf '%s' '${CHECKSUM_MANIFESTS.get("openshell-sandbox-checksums-sha256.txt")}' >"$output" + ;; + esac + ;; + *) exit 22 ;; +esac +`, + ); + fs.chmodSync(path.join(binDir, "curl"), 0o755); + return fixtureRoot; +} + +function runFixture( + mode: FixtureMode, + openshellVersion?: string, + trustedChecker = false, + formatting: PinFormatting = "canonical", +) { + const fixtureRoot = createFixture(openshellVersion, formatting); + const targetChecker = path.join(fixtureRoot, "scripts", "check-installer-hash.sh"); + const trustedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-trusted-hash-check-")); + const trustedCheckerPath = path.join(trustedRoot, "scripts", "check-installer-hash.sh"); + const trustedParserPath = path.join( + trustedRoot, + "scripts", + "checks", + "extract-installer-pins.mts", + ); + tempDirs.push(trustedRoot); + fs.mkdirSync(path.dirname(trustedParserPath), { recursive: true }); + fs.copyFileSync(path.join(REPO_ROOT, "scripts", "check-installer-hash.sh"), trustedCheckerPath); + fs.copyFileSync( + path.join(REPO_ROOT, "scripts", "checks", "extract-installer-pins.mts"), + trustedParserPath, + ); + fs.writeFileSync( + targetChecker, + trustedChecker + ? "#!/usr/bin/env bash\necho PR_CHECKER_EXECUTED\nexit 0\n" + : fs.readFileSync(targetChecker, "utf8"), + ); + const checker = trustedChecker ? trustedCheckerPath : targetChecker; + const installer = path.join(fixtureRoot, "scripts", "install-openshell.sh"); + const installerSource = fs.readFileSync(installer, "utf8"); + const mutateInstaller = INSTALLER_MUTATIONS[mode] ?? ((source: string) => source); + fs.writeFileSync(installer, mutateInstaller(installerSource)); + const brevInstaller = path.join(fixtureRoot, "scripts", "brev-launchable-ci-cpu.sh"); + const brevSource = fs.readFileSync(brevInstaller, "utf8"); + const mutateBrev = BREV_MUTATIONS[mode] ?? ((source: string) => source); + fs.writeFileSync(brevInstaller, mutateBrev(brevSource)); + const targetParser = path.join(fixtureRoot, "scripts", "checks", "extract-installer-pins.mts"); + fs.writeFileSync( + targetParser, + mode === "pr-parser-bypass" + ? 'process.stdout.write("PR_PARSER_EXECUTED\\n");\n' + : fs.readFileSync(targetParser, "utf8"), + ); + INPUT_MUTATIONS[mode]?.({ brevInstaller, fixtureRoot, installer }); + return spawnSync("bash", [checker], { + cwd: fixtureRoot, + encoding: "utf8", + env: { + ...process.env, + GITHUB_TOKEN: "", + GH_TOKEN: "", + NEMOCLAW_INSTALLER_HASH_REPO_ROOT: trustedChecker ? fixtureRoot : "", + NEMOCLAW_TEST_CURL_MODE: + mode.includes("bypass") || mode === "brev-mismatch" ? "complete" : mode, + PATH: `${path.join(fixtureRoot, "bin")}:${process.env.PATH ?? ""}`, + }, + }); +} + +describe("installer hash verification", () => { + it("verifies all installer and Brev pins from token-free checksum manifests", () => { + const result = runFixture("complete"); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("All installer hashes are current"); + }); + + it("uses the single release-version constant for release URLs and pin selection", () => { + const result = runFixture("complete", "9.9.9"); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Checking OpenShell v9.9.9 release assets"); + expect(result.stdout).toContain("All installer hashes are current"); + }); + + it.each([ + "equals-whitespace", + "comments", + "line-continuations", + "quote-styles", + "mixed-whitespace", + ] as const)("extracts pins across %s formatting", (formatting) => { + const result = runFixture("complete", undefined, false, formatting); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("All installer hashes are current"); + }); + + it("lets trusted checker code inspect a separate pull-request tree", () => { + const result = runFixture("complete", undefined, true); + + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("PR_CHECKER_EXECUTED"); + expect(result.stdout).toContain("All installer hashes are current"); + }); + + it.each([ + "missing-brev-pin", + "duplicate-brev-pin", + ] as const)("fails closed when the pull-request tree has a %s", (mode) => { + const result = runFixture(mode, undefined, true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("unable to extract the OpenShell installer pin tables"); + expect(result.stdout).toContain("expected 2 pinned Brev OpenShell v0.0.72 CLI assets"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it("does not let a pull request replace the trusted verifier with a success stub", () => { + const result = runFixture("pr-checker-bypass", undefined, true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "STALE: Brev launchable openshell-x86_64-unknown-linux-musl.tar.gz", + ); + expect(result.stdout).not.toContain("PR_CHECKER_EXECUTED"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it("does not let a pull request replace the trusted parser with a success stub", () => { + const result = runFixture("pr-parser-bypass", undefined, true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "STALE: Brev launchable openshell-x86_64-unknown-linux-musl.tar.gz", + ); + expect(result.stdout).not.toContain("PR_PARSER_EXECUTED"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it.each([ + ["symlink-installer-input", "installer input must be a regular file and not a symbolic link"], + [ + "non-regular-brev-input", + "Brev launchable input must be a regular file and not a symbolic link", + ], + ["oversized-installer-input", "installer input exceeds the 1048576-byte limit"], + [ + "symlink-scripts-parent", + "installer input parent must be a real directory and not a symbolic link", + ], + ] as const)("fails closed for %s", (mode, diagnostic) => { + const result = runFixture(mode, undefined, true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("unable to extract the OpenShell installer pin tables"); + expect(result.stdout).toContain(diagnostic); + expect(result.stdout).not.toContain("All installer hashes are current"); + expect(result.stdout).not.toContain(SYMLINK_INPUT_MARKER); + expect(result.stderr).not.toContain(SYMLINK_INPUT_MARKER); + }); + + it("fails closed when the OpenShell checksum release assets are unreachable", () => { + const result = runFixture("failure"); + + expect(result.status).not.toBe(0); + expect(result.stdout).toContain("Checking OpenShell v0.0.72 release assets"); + expect(result.stdout).toContain("14 OpenShell release-asset check(s) failed"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it("fails closed when an OpenShell checksum manifest is incomplete", () => { + const result = runFixture("partial"); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("digest does not match the pinned v0.0.72 release asset"); + expect(result.stdout).toContain("expected all 10 pinned asset references"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it("fails closed when one OpenShell checksum manifest returns HTTP 404", () => { + const result = runFixture("partial-manifest-missing"); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("OK: openshell-checksums-sha256.txt"); + expect(result.stdout).toContain( + "STALE: unable to download openshell-gateway-checksums-sha256.txt", + ); + expect(result.stdout).toContain("OK: openshell-sandbox-checksums-sha256.txt"); + expect(result.stderr).toContain("requested URL returned error: 404"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it("fails closed when a pinned installer asset is absent from every manifest", () => { + const result = runFixture("partial-asset-missing"); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + `STALE: installer ${UNPUBLISHED_ASSET} does not match exactly one v0.0.72 checksum entry`, + ); + expect(result.stdout).toContain("upstream: missing"); + expect(result.stdout).toContain("matches: 0"); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); + + it("fails closed when the Brev launchable pin drifts from the release manifest", () => { + const result = runFixture("brev-mismatch"); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "STALE: Brev launchable openshell-x86_64-unknown-linux-musl.tar.gz", + ); + expect(result.stdout).not.toContain("All installer hashes are current"); + }); +}); diff --git a/test/package-contract/openshell-policy-boundary.test.ts b/test/package-contract/openshell-policy-boundary.test.ts new file mode 100644 index 00000000000..73eb17ff3aa --- /dev/null +++ b/test/package-contract/openshell-policy-boundary.test.ts @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +import { auditOpenShellPolicyBoundaryDependencies } from "../../scripts/checks/verify-openshell-policy-boundary-dependencies.mts"; + +const repoRoot = path.join(import.meta.dirname, "..", ".."); +const require = createRequire(import.meta.url); + +function packageFiles(packageRoot: string): string[] { + const packageJson = JSON.parse( + fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"), + ) as { files?: string[] }; + return packageJson.files ?? []; +} + +describe("OpenShell policy boundary package contract", () => { + it("pins the YAML parser used by both production package boundaries", () => { + for (const packageRoot of [repoRoot, path.join(repoRoot, "nemoclaw")]) { + const dependencyVersion = JSON.parse( + execFileSync("npm", ["pkg", "get", "dependencies.yaml"], { + cwd: packageRoot, + encoding: "utf8", + }), + ) as string; + + expect(dependencyVersion).toBe("2.8.3"); + } + }); + + it("routes the CommonJS CLI and ESM plugin through one canonical CJS boundary", async () => { + const cliPolicy = require("../../dist/lib/policy/merge.js") as { + parseOpenShellPolicy: (raw: string) => { + yamlBody: string; + policy: Record; + }; + withoutProviderComposedPolicies: ( + policies: Record, + ) => Record; + stripProviderComposedPolicies: (policy: string) => string; + }; + expect( + cliPolicy.withoutProviderComposedPolicies({ safe: {}, _provider_generated: {} }), + ).toEqual({ safe: {} }); + + const pluginBoundary = (await import( + pathToFileURL( + path.join(repoRoot, "nemoclaw", "dist", "shared", "openshell-policy-boundary.cjs"), + ).href + )) as { + parseOpenShellPolicy: (raw: string) => { + yamlBody: string; + policy: Record; + }; + withoutProviderComposedPolicies: ( + policies: Record, + ) => Record; + stripProviderComposedPolicies: (policy: string) => string; + }; + const canonicalBoundary = + require("../../nemoclaw/dist/shared/openshell-policy-boundary.cjs") as { + parseOpenShellPolicy: typeof cliPolicy.parseOpenShellPolicy; + stripProviderComposedPolicies: typeof cliPolicy.stripProviderComposedPolicies; + }; + expect( + pluginBoundary.withoutProviderComposedPolicies({ safe: {}, _provider_generated: {} }), + ).toEqual({ safe: {} }); + + const policy = YAML.stringify({ + version: 1, + future_policy: { keep: true }, + network_policies: { safe: {}, _provider_generated: {} }, + }); + expect(YAML.parse(cliPolicy.stripProviderComposedPolicies(policy))).toEqual( + YAML.parse(pluginBoundary.stripProviderComposedPolicies(policy)), + ); + expect(() => cliPolicy.stripProviderComposedPolicies("version: [unterminated")).toThrow(); + expect(() => pluginBoundary.stripProviderComposedPolicies("version: [unterminated")).toThrow(); + + const policyOutput = ["Version: 1", "Hash: sha256:test", "---", policy].join("\n"); + expect(cliPolicy.parseOpenShellPolicy(policyOutput)).toEqual( + pluginBoundary.parseOpenShellPolicy(policyOutput), + ); + expect(cliPolicy.parseOpenShellPolicy).toBe(canonicalBoundary.parseOpenShellPolicy); + expect(cliPolicy.stripProviderComposedPolicies).toBe( + canonicalBoundary.stripProviderComposedPolicies, + ); + + const pluginRunner = await import( + pathToFileURL(path.join(repoRoot, "nemoclaw", "dist", "blueprint", "runner.js")).href + ); + expect(pluginRunner.actionApply).toBeTypeOf("function"); + }); + + it("loads the source plugin runner through the tsx subprocess boundary", () => { + const runnerPath = path.join(repoRoot, "nemoclaw", "src", "blueprint", "runner.ts"); + const output = execFileSync( + process.execPath, + [ + path.join(repoRoot, "node_modules", "tsx", "dist", "cli.mjs"), + "--input-type=module", + "--eval", + `const runner = await import(${JSON.stringify(pathToFileURL(runnerPath).href)}); process.stdout.write(typeof runner.actionApply);`, + ], + { cwd: repoRoot, encoding: "utf8" }, + ); + + expect(output).toBe("function"); + }); + + it("preserves fail-soft CLI parsing while the canonical runner parser stays strict", () => { + const cliPolicy = require("../../dist/lib/policy/index.js") as { + parseCurrentPolicy: (raw: string | null | undefined) => string; + }; + const canonical = require("../../nemoclaw/dist/shared/openshell-policy-boundary.cjs") as { + parseOpenShellPolicy: (raw: string) => { + yamlBody: string; + policy: Record; + }; + }; + const policyBody = "version: 1\nnetwork_policies:\n safe: {}"; + const policyOutput = ["Version: 1", "Hash: sha256:test", "---", policyBody].join("\n"); + + expect(cliPolicy.parseCurrentPolicy(policyOutput)).toBe(policyBody); + expect(canonical.parseOpenShellPolicy(policyOutput)).toEqual({ + yamlBody: policyBody, + policy: YAML.parse(policyBody), + }); + + const versionlessBody = "some_key:\n keep: true"; + expect(cliPolicy.parseCurrentPolicy(versionlessBody)).toBe(""); + expect(() => canonical.parseOpenShellPolicy(versionlessBody)).toThrow( + /does not contain a policy YAML document/, + ); + expect(cliPolicy.parseCurrentPolicy("Version: 1\nHash: sha256:test")).toBe(""); + expect(() => canonical.parseOpenShellPolicy("Version: 1\nHash: sha256:test")).toThrow( + /does not contain a policy YAML document/, + ); + expect(cliPolicy.parseCurrentPolicy("version: [unterminated")).toBe(""); + + const versionlessNetworkPolicies = "network_policies:\n safe: {}"; + expect(cliPolicy.parseCurrentPolicy(versionlessNetworkPolicies)).toBe( + versionlessNetworkPolicies, + ); + }); + + it("ships the generated canonical CJS boundary through both package manifests", () => { + expect(packageFiles(repoRoot)).toContain("nemoclaw/dist/"); + expect(packageFiles(path.join(repoRoot, "nemoclaw"))).toContain("dist/"); + + expect( + fs.existsSync( + path.join(repoRoot, "nemoclaw", "src", "shared", "openshell-policy-boundary.cts"), + ), + ).toBe(true); + expect( + fs.existsSync( + path.join(repoRoot, "nemoclaw", "dist", "shared", "openshell-policy-boundary.cjs"), + ), + ).toBe(true); + expect( + fs.existsSync( + path.join(repoRoot, "nemoclaw", "dist", "shared", "openshell-policy-boundary.d.cts"), + ), + ).toBe(true); + expect( + fs.existsSync( + path.join(repoRoot, "nemoclaw", "dist", "shared", "openshell-policy-boundary.js"), + ), + ).toBe(false); + }); + + it("locks the generated sandbox boundary to its reviewed direct dependency", () => { + const boundaryPath = path.join( + repoRoot, + "nemoclaw", + "dist", + "shared", + "openshell-policy-boundary.cjs", + ); + expect(auditOpenShellPolicyBoundaryDependencies(fs.readFileSync(boundaryPath, "utf8"))).toEqual( + ["yaml"], + ); + + expect(() => + auditOpenShellPolicyBoundaryDependencies('require("unexpected-package");'), + ).toThrow(/non-whitelisted modules: unexpected-package/); + expect(() => + auditOpenShellPolicyBoundaryDependencies('const dependency = "yaml"; require(dependency);'), + ).toThrow(/non-literal module load/); + + const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); + expect(dockerfile).toContain("verify-openshell-policy-boundary-dependencies.mts"); + expect(dockerfile).toContain("dist/shared/openshell-policy-boundary.cjs"); + }); +}); diff --git a/test/policies.test.ts b/test/policies.test.ts index df83d4922cc..ce684f9ce6c 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -722,12 +722,16 @@ exit 1 describe("applyPreset disclosure logging", () => { it("logs egress endpoints before applying", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-disclosure-")); + const fakeOpenshell = path.join(tmpDir, "openshell"); + fs.writeFileSync( + fakeOpenshell, + "#!/bin/sh\nprintf 'version: 1\\nnetwork_policies: {}\\n'\nexit 0\n", + { mode: 0o755 }, + ); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("exit"); - }); - + vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", fakeOpenshell); try { try { policies.applyPreset("test-sandbox", "npm"); @@ -743,7 +747,8 @@ exit 1 } finally { logSpy.mockRestore(); errSpy.mockRestore(); - exitSpy.mockRestore(); + vi.unstubAllEnvs(); + fs.rmSync(tmpDir, { recursive: true, force: true }); } }); @@ -850,14 +855,6 @@ exit 1 }); }); - describe("buildPolicyGetCommand", () => { - it("returns an argv array with sandbox name as a separate element", () => { - const cmd = policies.buildPolicyGetCommand("my-assistant"); - expect(cmd[0]).toMatch(/openshell$/); - expect(cmd.slice(1)).toEqual(["policy", "get", "--full", "my-assistant"]); - }); - }); - // Regression for issue #4224: when openshell is installed at ~/.local/bin/openshell // (the installer's user-local location) but PATH from a non-interactive shell does // not include ~/.local/bin/, buildPolicySetCommand / buildPolicyGetCommand must @@ -874,7 +871,11 @@ exit 1 const localBin = path.join(tmpHome, ".local", "bin"); fs.mkdirSync(localBin, { recursive: true }); fakeOpenshell = path.join(localBin, "openshell"); - fs.writeFileSync(fakeOpenshell, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync( + fakeOpenshell, + "#!/bin/sh\nprintf 'version: 1\\nnetwork_policies: {}\\n'\nexit 0\n", + { mode: 0o755 }, + ); origHome = process.env.HOME; origPath = process.env.PATH; @@ -912,7 +913,7 @@ exit 1 it("buildPolicyGetCommand resolves openshell to ~/.local/bin/openshell when PATH lacks it", () => { const cmd = policies.buildPolicyGetCommand("my-assistant"); expect(cmd[0]).toBe(fakeOpenshell); - expect(cmd).toEqual([fakeOpenshell, "policy", "get", "--full", "my-assistant"]); + expect(cmd).toEqual([fakeOpenshell, "policy", "get", "--base", "my-assistant"]); }); it("assertOpenshellResolvable emits a diagnostic listing every checked location and exits nonzero when openshell cannot be resolved", () => { @@ -977,7 +978,10 @@ exit 1 it("applyPreset does not create temp dirs before the openshell resolvability check", () => { const policyTempPrefix = path.join(os.tmpdir(), "nemoclaw-policy-"); - const resolveSpy = vi.spyOn(resolveOpenshellModule, "resolveOpenshell").mockReturnValue(null); + const resolveSpy = vi + .spyOn(resolveOpenshellModule, "resolveOpenshell") + .mockReturnValueOnce(fakeOpenshell) + .mockReturnValue(null); const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -1010,7 +1014,6 @@ exit 1 const CUSTOM = "network_policies:\n example:\n host: example.com\n"; const DEGRADED = '#!/bin/sh\nif [ "$1" = "policy" ] && [ "$2" = "get" ]; then echo "error: gateway is restarting"; fi\nexit 0\n'; - const EMPTY_OK = "#!/bin/sh\nexit 0\n"; let tmpHome: string; let fakeOpenshell: string; @@ -1067,25 +1070,6 @@ exit 1 } }); - it("still applies applyPresetContent when policy get returns an empty policy (fresh sandbox)", () => { - fs.writeFileSync(fakeOpenshell, EMPTY_OK, { mode: 0o755 }); - const logs: string[] = []; - const logSpy = vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { - logs.push(a.map((x) => String(x)).join(" ")); - }); - const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - try { - const result = policies.applyPresetContent("alpha", "my-custom", CUSTOM, { - custom: { sourcePath: "/tmp/x.yaml" }, - }); - expect(result).toBe(true); - expect(logs.join("\n")).toContain("Applied preset:"); - } finally { - logSpy.mockRestore(); - errSpy.mockRestore(); - } - }); - it("aborts applyPresets (returns false) when policy get exits 0 with degraded output", () => { fs.writeFileSync(fakeOpenshell, DEGRADED, { mode: 0o755 }); const errs: string[] = []; @@ -1123,7 +1107,11 @@ exit 1 const localBin = path.join(tmpHome, ".local", "bin"); fs.mkdirSync(localBin, { recursive: true }); fakeOpenshell = path.join(localBin, "openshell"); - fs.writeFileSync(fakeOpenshell, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync( + fakeOpenshell, + "#!/bin/sh\nprintf 'version: 1\\nnetwork_policies: {}\\n'\nexit 0\n", + { mode: 0o755 }, + ); origHome = process.env.HOME; process.env.HOME = tmpHome; resolveSpy = vi @@ -1334,20 +1322,17 @@ exit 1 }); describe("mergePresetIntoPolicy", () => { - // Legacy list-style entries (backward compat — uses text-based fallback) - const sampleEntries = " - host: example.com\n allow: true"; + const sampleEntries = " example:\n endpoints:\n - host: example.com"; - it("appends network_policies when current policy has content but no version header", () => { + it("refuses an unmarked current mapping without a policy root", () => { const versionless = "some_key:\n foo: bar"; - const merged = policies.mergePresetIntoPolicy(versionless, sampleEntries); - expect(merged).toContain("version:"); - expect(merged).toContain("some_key:"); - expect(merged).toContain("network_policies:"); - expect(merged).toContain("example.com"); + expect(() => policies.mergePresetIntoPolicy(versionless, sampleEntries)).toThrow( + /current policy is not a valid YAML mapping/, + ); }); it("appends preset entries when current policy has network_policies but no version", () => { - const versionlessWithNp = "network_policies:\n - host: existing.com\n allow: true"; + const versionlessWithNp = "network_policies:\n existing:\n host: existing.com"; const merged = policies.mergePresetIntoPolicy(versionlessWithNp, sampleEntries); expect(merged).toContain("version:"); expect(merged).toContain("existing.com"); @@ -1355,7 +1340,7 @@ exit 1 }); it("keeps existing version when present", () => { - const withVersion = "version: 2\n\nnetwork_policies:\n - host: old.com"; + const withVersion = "version: 2\nnetwork_policies:\n old:\n host: old.com"; const merged = policies.mergePresetIntoPolicy(withVersion, sampleEntries); expect(merged).toContain("version: 2"); expect(merged).toContain("example.com"); @@ -1368,19 +1353,20 @@ exit 1 expect(merged).toContain("example.com"); }); - it("rebuilds from a clean scaffold when current policy read is truncated", () => { - const merged = policies.mergePresetIntoPolicy("Version: 3\nHash: abc123", sampleEntries); - expect(merged).toBe( - "version: 1\n\nnetwork_policies:\n - host: example.com\n allow: true", - ); + it("fails closed when the current policy read is truncated", () => { + expect(() => + policies.mergePresetIntoPolicy("Version: 3\nHash: abc123", sampleEntries), + ).toThrow(/Cannot merge policy preset: the current policy is not a valid YAML mapping/); }); - it("adds a blank line after synthesized version headers", () => { - const merged = policies.mergePresetIntoPolicy("some_key:\n foo: bar", sampleEntries); - expect(merged.startsWith("version: 1\n\nsome_key:")).toBe(true); + it("fails closed when preset entries are malformed or not a mapping", () => { + for (const invalidEntries of [" broken: [unterminated", " - host: example.com"]) { + expect(() => policies.mergePresetIntoPolicy("version: 1", invalidEntries)).toThrow( + /preset network_policies entries must be a valid YAML mapping/, + ); + } }); - // --- Structured merge tests (real preset format) --- const realisticEntries = " pypi_access:\n" + " name: pypi_access\n" + @@ -2167,11 +2153,11 @@ exit 1 expect(result).not.toContain("pypi"); }); - it("returns policy unchanged when network_policies is a legacy array", () => { + it("rejects removal when network_policies is a legacy array", () => { const current = "version: 1\n\nnetwork_policies:\n - host: pypi.org\n allow: true\n"; - const result = policies.removePresetFromPolicy(current, pypiEntries); - expect(result).toContain("pypi.org"); - expect(result).toContain("allow: true"); + expect(() => policies.removePresetFromPolicy(current, pypiEntries)).toThrow( + /current policy is not a valid YAML mapping/i, + ); }); }); diff --git a/test/policy-diagnostic-read.test.ts b/test/policy-diagnostic-read.test.ts new file mode 100644 index 00000000000..c29a3cb8701 --- /dev/null +++ b/test/policy-diagnostic-read.test.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +const requireForTest = createRequire(import.meta.url); +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const policies = requireForTest( + path.join(REPO_ROOT, "src", "lib", "policy", "index.ts"), +) as typeof import("../src/lib/policy"); + +describe("OpenShell policy read boundaries", () => { + it("uses the base policy for mutation reads", () => { + const command = policies.buildPolicyGetCommand("my-assistant"); + expect(command[0]).toMatch(/openshell$/); + expect(command.slice(1)).toEqual(["policy", "get", "--base", "my-assistant"]); + }); + + it("uses the full effective policy for diagnostic reads", () => { + const command = policies.buildPolicyGetFullCommand("my-assistant"); + expect(command[0]).toMatch(/openshell$/); + expect(command.slice(1)).toEqual(["policy", "get", "--full", "my-assistant"]); + }); + + it("queries the full effective policy when matching gateway presets", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-diagnostic-")); + const fakeOpenshell = path.join(tmpDir, "openshell"); + const argsFile = path.join(tmpDir, "args.txt"); + fs.writeFileSync( + fakeOpenshell, + [ + "#!/bin/sh", + `printf "%s\\n" "$*" >${JSON.stringify(argsFile)}`, + "printf 'Version: 1\\n---\\nversion: 1\\nnetwork_policies: {}\\n'", + ].join("\n"), + { mode: 0o755 }, + ); + + vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", fakeOpenshell); + try { + expect(policies.getGatewayPresets("my-assistant")).toEqual([]); + expect(fs.readFileSync(argsFile, "utf-8").trim()).toBe("policy get --full my-assistant"); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/policy-mutation-read-discovery.test.ts b/test/policy-mutation-read-discovery.test.ts new file mode 100644 index 00000000000..6c02027145f --- /dev/null +++ b/test/policy-mutation-read-discovery.test.ts @@ -0,0 +1,43 @@ +// 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 { describe, expect, it } from "vitest"; + +import { + auditOpenShellPolicyMutationReads, + discoverPolicyReadSites, +} from "../scripts/checks/openshell-policy-mutation-read"; + +describe("OpenShell policy mutation read discovery", () => { + it("discovers builder and direct policy reads in new production files", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-read-discovery-")); + const mutationPath = path.join(repoRoot, "src", "lib", "new-policy-mutation.ts"); + const diagnosticPath = path.join(repoRoot, "nemoclaw", "src", "new-policy-diagnostic.ts"); + fs.mkdirSync(path.dirname(mutationPath), { recursive: true }); + fs.mkdirSync(path.dirname(diagnosticPath), { recursive: true }); + fs.writeFileSync(mutationPath, "runCapture(buildPolicyGetCommand(sandboxName));\n"); + fs.writeFileSync( + diagnosticPath, + 'runCmd(["openshell", "policy", "get", "--full", sandboxName]);\n', + ); + + try { + expect(discoverPolicyReadSites(repoRoot)).toEqual([ + { relativePath: "nemoclaw/src/new-policy-diagnostic.ts", readCalls: 1 }, + { relativePath: "src/lib/new-policy-mutation.ts", readCalls: 1 }, + ]); + expect(auditOpenShellPolicyMutationReads(repoRoot)).toEqual( + expect.arrayContaining([ + expect.stringContaining("new-policy-diagnostic.ts: found 1 unaccounted policy read"), + expect.stringContaining("new-policy-mutation.ts: found 1 unaccounted policy read"), + ]), + ); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/test/policy-mutation-read-failure.test.ts b/test/policy-mutation-read-failure.test.ts new file mode 100644 index 00000000000..baf474da5c9 --- /dev/null +++ b/test/policy-mutation-read-failure.test.ts @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const requireForTest = createRequire(import.meta.url); +const policies = requireForTest( + path.join(import.meta.dirname, "..", "src", "lib", "policy", "index.ts"), +) as typeof import("../src/lib/policy"); +const CUSTOM_PRESET = "network_policies:\n example:\n host: example.com\n"; +const MALFORMED_BASE_POLICIES = [ + ["network_policies string", "version: 1\nnetwork_policies: invalid\n"], + ["network_policies sequence", "version: 1\nnetwork_policies: []\n"], + ["network_policies null", "version: 1\nnetwork_policies: null\n"], + ["string version", 'version: "1"\nnetwork_policies: {}\n'], + ["fractional version", "version: 1.5\nnetwork_policies: {}\n"], +] as const; +const UNMARKED_NON_POLICY_MAPPINGS = [ + ["message diagnostic", "message: gateway unavailable\n"], + ["details diagnostic", "details: connection refused\n"], + ["arbitrary diagnostic", "reason: gateway unavailable\nretryable: true\n"], +] as const; + +describe("OpenShell policy mutation read failures", () => { + const tempDirs: string[] = []; + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + for (const [mutation, apply] of [ + ["applyPresetContent", () => policies.applyPresetContent("alpha", "custom", CUSTOM_PRESET)], + ["applyPresets", () => policies.applyPresets("alpha", ["npm"])], + ] as const) { + it(`${mutation} refuses to set policy when the base-policy read fails`, () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-read-failure-")); + tempDirs.push(tempDir); + const callsPath = path.join(tempDir, "calls.log"); + const fakeOpenshell = path.join(tempDir, "openshell"); + fs.writeFileSync( + fakeOpenshell, + ["#!/bin/sh", `printf '%s\\n' "$*" >>${JSON.stringify(callsPath)}`, "exit 42"].join("\n"), + { mode: 0o755 }, + ); + vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", fakeOpenshell); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(apply()).toBe(false); + const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); + expect(calls).toEqual(["policy get --base alpha"]); + expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("refusing to apply")); + }); + + for (const [outputName, emitOutput] of [ + ["empty", ":"], + ["whitespace-only", "printf ' \\n'"], + ] as const) { + it(`${mutation} refuses to set policy when the successful base-policy read is ${outputName}`, () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-empty-read-")); + tempDirs.push(tempDir); + const callsPath = path.join(tempDir, "calls.log"); + const fakeOpenshell = path.join(tempDir, "openshell"); + fs.writeFileSync( + fakeOpenshell, + [ + "#!/bin/sh", + `printf '%s\\n' "$*" >>${JSON.stringify(callsPath)}`, + emitOutput, + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", fakeOpenshell); + const policyTempPrefix = path.join(os.tmpdir(), "nemoclaw-policy-"); + const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(apply()).toBe(false); + const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); + expect(calls).toEqual(["policy get --base alpha"]); + expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); + expect( + mkdtempSpy.mock.calls.filter(([prefix]) => String(prefix).startsWith(policyTempPrefix)), + ).toEqual([]); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("refusing to apply")); + }); + } + + for (const [shapeName, policyOutput] of MALFORMED_BASE_POLICIES) { + it(`${mutation} refuses to set policy when the base-policy read has ${shapeName}`, () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-malformed-read-")); + tempDirs.push(tempDir); + const callsPath = path.join(tempDir, "calls.log"); + const outputPath = path.join(tempDir, "policy-output.yaml"); + const fakeOpenshell = path.join(tempDir, "openshell"); + fs.writeFileSync(outputPath, policyOutput); + fs.writeFileSync( + fakeOpenshell, + [ + "#!/bin/sh", + `printf '%s\\n' "$*" >>${JSON.stringify(callsPath)}`, + `cat ${JSON.stringify(outputPath)}`, + ].join("\n"), + { mode: 0o755 }, + ); + vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", fakeOpenshell); + const policyTempPrefix = path.join(os.tmpdir(), "nemoclaw-policy-"); + const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(apply()).toBe(false); + const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); + expect(calls).toEqual(["policy get --base alpha"]); + expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); + expect( + mkdtempSpy.mock.calls.filter(([prefix]) => String(prefix).startsWith(policyTempPrefix)), + ).toEqual([]); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("refusing to apply")); + }); + } + + for (const [shapeName, policyOutput] of UNMARKED_NON_POLICY_MAPPINGS) { + it(`${mutation} refuses to set policy when the successful base-policy read is an unmarked ${shapeName}`, () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-diagnostic-read-")); + tempDirs.push(tempDir); + const callsPath = path.join(tempDir, "calls.log"); + const outputPath = path.join(tempDir, "policy-output.yaml"); + const fakeOpenshell = path.join(tempDir, "openshell"); + fs.writeFileSync(outputPath, policyOutput); + fs.writeFileSync( + fakeOpenshell, + [ + "#!/bin/sh", + `printf '%s\\n' "$*" >>${JSON.stringify(callsPath)}`, + `cat ${JSON.stringify(outputPath)}`, + ].join("\n"), + { mode: 0o755 }, + ); + vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", fakeOpenshell); + const policyTempPrefix = path.join(os.tmpdir(), "nemoclaw-policy-"); + const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(apply()).toBe(false); + const calls = fs.readFileSync(callsPath, "utf-8").trim().split("\n"); + expect(calls).toEqual(["policy get --base alpha"]); + expect(calls.some((call) => call.startsWith("policy set "))).toBe(false); + expect( + mkdtempSpy.mock.calls.filter(([prefix]) => String(prefix).startsWith(policyTempPrefix)), + ).toEqual([]); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("refusing to apply")); + }); + } + } +}); diff --git a/test/policy-openshell-072-roundtrip.test.ts b/test/policy-openshell-072-roundtrip.test.ts new file mode 100644 index 00000000000..74c6f970e1f --- /dev/null +++ b/test/policy-openshell-072-roundtrip.test.ts @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +const requireForTest = createRequire(import.meta.url); +const YAML = requireForTest("yaml"); +const policies = requireForTest( + path.join(import.meta.dirname, "..", "src", "lib", "policy", "index.ts"), +) as typeof import("../src/lib/policy"); + +const EXISTING_POLICY = { + version: 1, + future_policy: { + opaque_setting: { keep: true }, + }, + filesystem_policy: { + default: "deny", + roots: ["/sandbox"], + }, + metadata: { + future_schema: "opaque", + preserve: true, + }, + network_policies: { + mcp_server: { + endpoints: [ + { + host: "mcp.example.com", + port: 443, + path: "/mcp", + protocol: "mcp", + enforcement: "enforce", + mcp: { + allow_all_known_mcp_methods: true, + max_body_bytes: 131072, + strict_tool_names: true, + }, + rules: [{ allow: { tool: { any: ["search_web", "list_tools"] } } }], + deny_rules: [{ tool: { any: ["send_email", "delete_resource"] } }], + }, + ], + }, + json_rpc_server: { + endpoints: [ + { + host: "rpc.example.com", + port: 443, + path: "/rpc", + protocol: "json-rpc", + enforcement: "enforce", + json_rpc: { max_body_bytes: 131072 }, + rules: [{ allow: { method: "reports.search" } }], + }, + ], + }, + }, +}; + +const PRESET_ENTRIES = YAML.stringify({ + pypi_access: { + name: "pypi_access", + endpoints: [{ host: "pypi.org", port: 443, access: "full" }], + }, +}).replace(/^/gm, " "); + +const CUSTOM_PRESET_ENTRIES = YAML.stringify({ + custom_registry: { + name: "custom_registry", + endpoints: [{ host: "registry.example.com", port: 443, access: "read-only" }], + }, +}).replace(/^/gm, " "); + +describe("OpenShell 0.0.72 policy round-trip compatibility", () => { + it("preserves MCP and JSON-RPC fields while merging a preset", () => { + const merged = YAML.parse( + policies.mergePresetIntoPolicy(YAML.stringify(EXISTING_POLICY), PRESET_ENTRIES), + ); + + expect(merged.network_policies).toEqual({ + ...EXISTING_POLICY.network_policies, + pypi_access: expect.any(Object), + }); + expect(merged.future_policy).toEqual(EXISTING_POLICY.future_policy); + expect(merged.filesystem_policy).toEqual(EXISTING_POLICY.filesystem_policy); + expect(merged.metadata).toEqual(EXISTING_POLICY.metadata); + }); + + it("preserves protocol fields across multiple built-in and custom-shaped merges", () => { + const first = policies.mergePresetIntoPolicy(YAML.stringify(EXISTING_POLICY), PRESET_ENTRIES); + const merged = YAML.parse(policies.mergePresetIntoPolicy(first, CUSTOM_PRESET_ENTRIES)); + + expect(merged.network_policies).toEqual({ + ...EXISTING_POLICY.network_policies, + pypi_access: expect.any(Object), + custom_registry: expect.any(Object), + }); + }); + + it.each([ + ["unterminated YAML", " malformed: [unterminated"], + ["an array", " - host: example.com"], + ["a scalar policy value", " key: scalar"], + ["an empty mapping", " {}"], + ["non-mapping content", " not yaml at all"], + ])("rejects preset entries containing %s", (_shape, presetEntries) => { + expect(() => + policies.mergePresetIntoPolicy(YAML.stringify(EXISTING_POLICY), presetEntries), + ).toThrow(/preset network_policies entries must be a valid YAML mapping/); + }); + + it("preserves MCP and JSON-RPC fields when removing a merged preset", () => { + const merged = policies.mergePresetIntoPolicy(YAML.stringify(EXISTING_POLICY), PRESET_ENTRIES); + const removed = YAML.parse(policies.removePresetFromPolicy(merged, PRESET_ENTRIES)); + + expect(removed.network_policies).toEqual(EXISTING_POLICY.network_policies); + expect(removed.future_policy).toEqual(EXISTING_POLICY.future_policy); + expect(removed.filesystem_policy).toEqual(EXISTING_POLICY.filesystem_policy); + expect(removed.metadata).toEqual(EXISTING_POLICY.metadata); + }); + + it("drops provider-composed entries from merge and removal mutation payloads", () => { + const taintedPolicy = { + ...EXISTING_POLICY, + network_policies: { + ...EXISTING_POLICY.network_policies, + _provider_unexpected: { name: "must-not-round-trip" }, + }, + }; + const merged = policies.mergePresetIntoPolicy(YAML.stringify(taintedPolicy), PRESET_ENTRIES); + const removed = YAML.parse(policies.removePresetFromPolicy(merged, PRESET_ENTRIES)); + + expect(YAML.parse(merged).network_policies).not.toHaveProperty("_provider_unexpected"); + expect(removed.network_policies).toEqual(EXISTING_POLICY.network_policies); + }); + + it("does not let custom preset input author reserved provider-composed entries", () => { + const reservedEntries = YAML.stringify({ + _provider_injected: { name: "must-not-submit" }, + }).replace(/^/gm, " "); + const merged = YAML.parse( + policies.mergePresetIntoPolicy(YAML.stringify(EXISTING_POLICY), reservedEntries), + ); + + expect(merged.network_policies).toEqual(EXISTING_POLICY.network_policies); + }); + + it("rejects custom preset files that author reserved provider-composed entries", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-provider-preset-")); + const presetPath = path.join(tempDir, "reserved.yaml"); + try { + fs.writeFileSync( + presetPath, + YAML.stringify({ + preset: { name: "reserved-entry" }, + network_policies: { _provider_injected: { name: "must-not-load" } }, + }), + ); + + expect(policies.loadPresetFromFile(presetPath)).toBeNull(); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects custom preset names reserved for provider-composed entries", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-provider-preset-name-")); + const presetPath = path.join(tempDir, "reserved-name.yaml"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + fs.writeFileSync( + presetPath, + YAML.stringify({ + preset: { name: "_provider_injected" }, + network_policies: { safe_entry: { name: "safe-entry" } }, + }), + ); + + expect(policies.loadPresetFromFile(presetPath)).toBeNull(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("Preset name cannot start with '_provider_'"), + ); + } finally { + consoleError.mockRestore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects a legacy network_policies array instead of replacing its entries", () => { + const legacy = YAML.stringify({ + version: 1, + network_policies: [{ host: "legacy.example.com", access: "full" }], + }); + + expect(() => policies.mergePresetIntoPolicy(legacy, PRESET_ENTRIES)).toThrow( + /current policy is not a valid YAML mapping/i, + ); + }); +}); diff --git a/test/policy-roundtrip-docs.test.ts b/test/policy-roundtrip-docs.test.ts index 815d41c776e..efb4edeaa27 100644 --- a/test/policy-roundtrip-docs.test.ts +++ b/test/policy-roundtrip-docs.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; @@ -14,7 +15,7 @@ const DOCS = [ ]; const SOURCE_REVIEW_MARKERS = [ - "invalidState: OpenShell 0.0.44 policy get --full emits metadata before the --- YAML header.", + "invalidState: OpenShell 0.0.72 policy get --base emits metadata before the --- YAML header.", "sourceBoundary: OpenShell CLI output is owned by the separate OpenShell project.", "whyNotSourceFix: NemoClaw pins OpenShell but cannot change that upstream formatter here.", "regressionTest: test/policy-roundtrip-docs.test.ts validates this shared docs pattern.", @@ -30,17 +31,35 @@ function bashBlocks(text: string): string[] { } describe("policy round-trip documentation examples", () => { + it("executes the documented extractor against OpenShell 0.0.72 base output", () => { + const extractor = "awk 'found { print } /^---$/ { found = 1 } END { if (!found) exit 1 }'"; + const valid = spawnSync("bash", ["-o", "pipefail", "-c", extractor], { + encoding: "utf8", + input: "Version: 1\nHash: sha256:test\n---\nversion: 1\nnetwork_policies: {}\n", + }); + expect(valid.status, valid.stderr).toBe(0); + expect(valid.stdout).toBe("version: 1\nnetwork_policies: {}\n"); + + const missingHeader = spawnSync("bash", ["-o", "pipefail", "-c", extractor], { + encoding: "utf8", + input: "version: 1\nnetwork_policies: {}\n", + }); + expect(missingHeader.status).not.toBe(0); + expect(missingHeader.stdout).toBe(""); + }); + it("keeps raw policy get/set snippets aligned with NemoClaw's OpenShell command builders", () => { for (const docPath of DOCS) { const text = readDoc(docPath); - expect(text, docPath).toContain("OpenShell 0.0.44+"); - expect(text, docPath).toMatch(/openshell policy get --full (?:my-assistant|)/); + expect(text, docPath).toContain("OpenShell 0.0.72+"); + expect(text, docPath).toMatch(/openshell policy get --base (?:my-assistant|)/); expect(text, docPath).toMatch( /openshell policy set --policy current-policy\.yaml --wait (?:my-assistant|)/, ); expect(text, docPath).not.toMatch( - /openshell policy get (?:my-assistant|) --full/, + /openshell policy get (?:my-assistant|) --base/, ); + expect(text, docPath).not.toMatch(/openshell policy get --full/); expect(text, docPath).not.toMatch( /openshell policy set (?:my-assistant|) --policy/, ); diff --git a/test/pr-workflow-contract.test.ts b/test/pr-workflow-contract.test.ts index 7d688cf9fc4..d6d5f3e50e6 100644 --- a/test/pr-workflow-contract.test.ts +++ b/test/pr-workflow-contract.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -15,9 +15,15 @@ import { } from "./helpers/e2e-workflow-contract"; type CiWorkflow = { + on?: { pull_request?: { paths?: string[] } }; + permissions?: Record; jobs: Record; }; +type InstallerHashAction = CompositeAction & { + inputs?: Record; +}; + type CodebaseGrowthGuardrailsWorkflow = { jobs: Record; }; @@ -48,6 +54,11 @@ const trustedPrActionPaths = { } as const; const trustedCheckoutAction = "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10"; +const trustedSetupNodeAction = "actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"; +const installerHashBootstrapCommit = "cb5e9aefab2b16fedc0995149fc3520da0d5e0c7"; +const installerHashBootstrapTree = "1fdf59efe40b78c407e222fd42043b23a61e199a"; +const installerHashBootstrapCreatedAt = "2026-07-02T19:35:41Z"; +const installerHashBootstrapExpiresAt = "2026-12-29T19:35:41Z"; const trustedActionDirs = [ ".github/actions/ci-static-checks", @@ -138,6 +149,22 @@ function requiredWorkflowStepIndex(job: WorkflowJob, stepName: string): number { return stepIndex; } +function runWorkflowShellStep( + step: WorkflowStep, + env: Record, +): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync("bash", ["-c", step.run ?? ""], { + encoding: "utf8", + env: { ...process.env, ...step.env, ...env }, + timeout: 5_000, + }); + return { + status: result.status, + stdout: String(result.stdout), + stderr: String(result.stderr), + }; +} + function codeFilterMatchesChangedPaths(workflow: CiWorkflow, paths: string[]): boolean { const filterStep = workflow.jobs.changes.steps?.find((step) => step.id === "filter"); const quantifier = filterStep?.with?.["predicate-quantifier"]; @@ -175,6 +202,10 @@ function codeFilterMatchesChangedPaths(workflow: CiWorkflow, paths: string[]): b describe("pull request and main workflow contracts", () => { const prWorkflow = readYaml(".github/workflows/pr.yaml"); const mainWorkflow = readYaml(".github/workflows/main.yaml"); + const installerHashWorkflow = readYaml(".github/workflows/installer-hash-check.yaml"); + const installerHashAction = readYaml( + ".github/actions/ci-installer-hash-check/action.yaml", + ); const prekConfig = readYaml(".pre-commit-config.yaml"); const sharedActions = { staticChecks: readYaml(".github/actions/ci-static-checks/action.yaml"), @@ -194,6 +225,259 @@ describe("pull request and main workflow contracts", () => { ".github/actions/resolve-hermes-base-image/action.yaml", ); + it("runs pull request installer verification from immutable trusted code", () => { + const job = installerHashWorkflow.jobs["check-hash"]; + const parserRuntimeSetup = requiredWorkflowStep( + job, + "Set up trusted installer hash parser runtime", + ); + const prCheckout = requiredWorkflowStep(job, "Checkout pull request head"); + const baseCheckout = requiredWorkflowStep(job, "Checkout base-trusted installer hash action"); + const trustedActionProbe = requiredWorkflowStep( + job, + "Detect base-trusted installer hash action", + ); + const bootstrapCheckout = requiredWorkflowStep( + job, + "Checkout immutable installer hash bootstrap", + ); + const bootstrapTreeVerification = requiredWorkflowStep( + job, + "Verify immutable installer hash bootstrap tree", + ); + const bootstrapExpiry = requiredWorkflowStep( + job, + "Enforce immutable installer hash bootstrap expiry", + ); + const baseVerification = requiredWorkflowStep( + job, + "Verify pull request installer hashes from base-trusted code", + ); + const bootstrapVerification = requiredWorkflowStep( + job, + "Verify pull request installer hashes from immutable bootstrap", + ); + const trustedEventVerification = requiredWorkflowStep( + job, + "Verify trusted event installer hashes", + ); + + expect(installerHashWorkflow.on?.pull_request?.paths).toBeUndefined(); + expect(installerHashWorkflow.permissions).toEqual({ contents: "read" }); + expect(parserRuntimeSetup.uses).toBe(trustedSetupNodeAction); + expect(parserRuntimeSetup.with?.["node-version"]).toBe("22.16.0"); + expect(prCheckout.with?.repository).toBe( + "${{ github.event.pull_request.head.repo.full_name }}", + ); + expect(prCheckout.with?.ref).toBe("${{ github.event.pull_request.head.sha }}"); + + for (const checkout of (job.steps ?? []).filter( + (step) => step.uses === trustedCheckoutAction, + )) { + expect(checkout.with?.["persist-credentials"], checkout.name).toBe(false); + } + expect( + (job.steps ?? []) + .filter((step) => step.uses?.startsWith("actions/checkout@")) + .every((step) => step.uses === trustedCheckoutAction), + ).toBe(true); + + expect(baseCheckout.with?.ref).toBe("${{ github.event.pull_request.base.sha }}"); + expect(baseCheckout.with?.path).toBe(".trusted-installer-hash"); + expect(baseCheckout.with?.["sparse-checkout"]).toContain( + ".github/actions/ci-installer-hash-check", + ); + expect(baseCheckout.with?.["sparse-checkout"]).toContain("scripts/check-installer-hash.sh"); + expect(baseCheckout.with?.["sparse-checkout"]).toContain( + "scripts/checks/extract-installer-pins.mts", + ); + + expect(trustedActionProbe.id).toBe("trusted-installer-hash"); + expect(trustedActionProbe.run).toContain( + ".trusted-installer-hash/.github/actions/ci-installer-hash-check/action.yaml", + ); + expect(trustedActionProbe.run).not.toContain("scripts/check-installer-hash.sh"); + expect(bootstrapCheckout.with?.ref).toBe(installerHashBootstrapCommit); + expect(String(bootstrapCheckout.with?.ref)).toMatch(/^[a-f0-9]{40}$/u); + expect(bootstrapCheckout.with?.path).toBe(".bootstrap-installer-hash"); + expect(bootstrapCheckout.with?.["sparse-checkout"]).toContain( + ".github/actions/ci-installer-hash-check", + ); + expect(bootstrapCheckout.with?.["sparse-checkout"]).toContain( + "scripts/check-installer-hash.sh", + ); + expect(bootstrapCheckout.with?.["sparse-checkout"]).toContain( + "scripts/checks/extract-installer-pins.mts", + ); + expect(bootstrapCheckout.with?.["sparse-checkout-cone-mode"]).toBe(false); + expect((bootstrapExpiry as WorkflowStep & { shell?: string }).shell).toBe("bash"); + expect(bootstrapExpiry.env).toBeUndefined(); + expect(bootstrapExpiry.run).toContain(installerHashBootstrapCommit); + expect(bootstrapExpiry.run).toContain(installerHashBootstrapExpiresAt); + expect(bootstrapExpiry.if).toBe(bootstrapCheckout.if); + expect(bootstrapExpiry.if).toBe(bootstrapVerification.if); + expect(bootstrapTreeVerification.if).toBe(bootstrapCheckout.if); + expect(bootstrapTreeVerification.run).toContain(installerHashBootstrapCommit); + expect(bootstrapTreeVerification.run).toContain(installerHashBootstrapTree); + expect( + requiredWorkflowStepIndex(job, "Enforce immutable installer hash bootstrap expiry"), + ).toBeLessThan(requiredWorkflowStepIndex(job, "Checkout immutable installer hash bootstrap")); + expect( + requiredWorkflowStepIndex(job, "Checkout immutable installer hash bootstrap"), + ).toBeLessThan( + requiredWorkflowStepIndex(job, "Verify immutable installer hash bootstrap tree"), + ); + expect( + requiredWorkflowStepIndex(job, "Verify immutable installer hash bootstrap tree"), + ).toBeLessThan( + requiredWorkflowStepIndex( + job, + "Verify pull request installer hashes from immutable bootstrap", + ), + ); + expect( + requiredWorkflowStepIndex(job, "Set up trusted installer hash parser runtime"), + ).toBeLessThan( + requiredWorkflowStepIndex(job, "Verify pull request installer hashes from base-trusted code"), + ); + expect( + requiredWorkflowStepIndex(job, "Set up trusted installer hash parser runtime"), + ).toBeLessThan( + requiredWorkflowStepIndex( + job, + "Verify pull request installer hashes from immutable bootstrap", + ), + ); + expect( + requiredWorkflowStepIndex(job, "Set up trusted installer hash parser runtime"), + ).toBeLessThan(requiredWorkflowStepIndex(job, "Verify trusted event installer hashes")); + expect( + (Date.parse(installerHashBootstrapExpiresAt) - Date.parse(installerHashBootstrapCreatedAt)) / + 86_400_000, + ).toBe(180); + expect(bootstrapExpiry.run).toContain("Date.now() >= expiresAtMs"); + expect(bootstrapExpiry.run).toContain("Remove the bootstrap fallback"); + + expect(baseVerification.uses).toBe( + "./.trusted-installer-hash/.github/actions/ci-installer-hash-check", + ); + expect(bootstrapVerification.uses).toBe( + "./.bootstrap-installer-hash/.github/actions/ci-installer-hash-check", + ); + expect(trustedEventVerification.uses).toBe("./.github/actions/ci-installer-hash-check"); + expect(baseVerification.if).toBe( + "github.event_name == 'pull_request' && steps.trusted-installer-hash.outputs.available == 'true'", + ); + expect(bootstrapVerification.if).toBe( + "github.event_name == 'pull_request' && steps.trusted-installer-hash.outputs.available != 'true'", + ); + expect(trustedEventVerification.if).toBe("github.event_name != 'pull_request'"); + for (const verification of [ + baseVerification, + bootstrapVerification, + trustedEventVerification, + ]) { + expect(verification.with?.["repo-root"], verification.name).toBe("${{ github.workspace }}"); + } + + expect(job.steps?.some((step) => step.name === "Detect installer-affecting changes")).toBe( + false, + ); + expect(stepRuns(job).join("\n")).not.toContain("bash scripts/check-installer-hash.sh"); + }); + + it("fails closed when the immutable installer hash bootstrap expiry is mutated", () => { + const expiryStep = requiredWorkflowStep( + installerHashWorkflow.jobs["check-hash"], + "Enforce immutable installer hash bootstrap expiry", + ); + const expired = runWorkflowShellStep( + { + ...expiryStep, + run: expiryStep.run?.replace(installerHashBootstrapExpiresAt, "2000-12-27T23:26:13Z"), + }, + {}, + ); + const malformedExpiry = runWorkflowShellStep( + { + ...expiryStep, + run: expiryStep.run?.replace(installerHashBootstrapExpiresAt, "not-a-canonical-utc-date"), + }, + {}, + ); + const mutableRef = runWorkflowShellStep( + { + ...expiryStep, + run: expiryStep.run?.replace(installerHashBootstrapCommit, "main"), + }, + {}, + ); + const valid = runWorkflowShellStep(expiryStep, {}); + + expect(valid.status).toBe(0); + expect(valid.stdout).toContain("remains valid"); + expect(expired.status).not.toBe(0); + expect(expired.stderr).toContain("expired at 2000-12-27T23:26:13Z"); + expect(expired.stderr).toContain("Remove the bootstrap fallback"); + expect(malformedExpiry.status).not.toBe(0); + expect(malformedExpiry.stderr).toContain("expiry configuration is invalid"); + expect(mutableRef.status).not.toBe(0); + expect(mutableRef.stderr).toContain("refusing the fallback"); + }); + + it("fails closed when the immutable installer hash bootstrap tree differs", () => { + const treeStep = requiredWorkflowStep( + installerHashWorkflow.jobs["check-hash"], + "Verify immutable installer hash bootstrap tree", + ); + const fakeBin = mkdtempSync(join(tmpdir(), "nemoclaw-bootstrap-git-")); + const fakeGit = join(fakeBin, "git"); + writeFileSync( + fakeGit, + [ + "#!/bin/sh", + 'case "$*" in', + ' *"HEAD^{tree}"*) printf \'%s\\n\' "${FAKE_TREE}" ;;', + ` *) printf '%s\\n' ${installerHashBootstrapCommit} ;;`, + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const env = { + GITHUB_WORKSPACE: tmpdir(), + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + }; + const valid = runWorkflowShellStep(treeStep, { + ...env, + FAKE_TREE: installerHashBootstrapTree, + }); + const mismatch = runWorkflowShellStep(treeStep, { + ...env, + FAKE_TREE: "0000000000000000000000000000000000000000", + }); + + expect(valid.status).toBe(0); + expect(mismatch.status).not.toBe(0); + expect(mismatch.stderr).toContain("does not match the reviewed tree"); + } finally { + rmSync(fakeBin, { recursive: true, force: true }); + } + }); + + it("keeps the installer verifier inside the trusted composite action", () => { + const verification = requiredStep(installerHashAction, "Verify installer hashes are current"); + + expect(installerHashAction.inputs?.["repo-root"]?.required).toBe(true); + expect(verification.env).toEqual({ + NEMOCLAW_INSTALLER_HASH_REPO_ROOT: "${{ inputs.repo-root }}", + }); + expect(verification.run).toBe( + 'bash "${{ github.action_path }}/../../../scripts/check-installer-hash.sh"', + ); + }); + it("routes only code-changing PRs through the code-check path", () => { const filterStep = prWorkflow.jobs.changes.steps?.find((step) => step.id === "filter"); diff --git a/test/process-recovery.test.ts b/test/process-recovery.test.ts index 024acc2eab1..3898d8c7619 100644 --- a/test/process-recovery.test.ts +++ b/test/process-recovery.test.ts @@ -13,6 +13,9 @@ const requireSource = createRequire(import.meta.url); const { checkAndRecoverSandboxProcesses } = requireSource( "../src/lib/actions/sandbox/process-recovery.ts", ) as typeof import("../src/lib/actions/sandbox/process-recovery.js"); +const { ensureSandboxPortForwardForPort } = requireSource( + "../src/lib/actions/sandbox/forward-recovery.ts", +) as typeof import("../src/lib/actions/sandbox/forward-recovery.js"); afterEach(() => { vi.restoreAllMocks(); @@ -111,7 +114,8 @@ describe("checkAndRecoverSandboxProcesses", () => { beta 127.0.0.1 18789 12345 dead`; const runningForward = `SANDBOX BIND PORT PID STATUS beta 127.0.0.1 18789 12345 running`; - let forwardListCalls = 0; + let forwardStarted = false; + let postStartListCalls = 0; vi.spyOn(childProcess, "spawnSync").mockImplementation( (_command: unknown, rawArgs: unknown) => { @@ -136,19 +140,23 @@ beta 127.0.0.1 18789 12345 running`; agent: "openclaw", dashboardPort: 18789, }); - vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation(() => forwardStarted); vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation((rawArgs: unknown) => { const args = Array.isArray(rawArgs) ? rawArgs : []; expect(args).toEqual(["forward", "list"]); - forwardListCalls += 1; + postStartListCalls += Number(forwardStarted); return { status: 0, - output: forwardListCalls >= 3 ? runningForward : deadForward, + output: forwardStarted && postStartListCalls >= 2 ? runningForward : deadForward, }; }); const runOpenshell = vi .spyOn(openshellRuntime, "runOpenshell") - .mockReturnValue({ status: 0 } as never); + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + forwardStarted = forwardStarted || (args[0] === "forward" && args[1] === "start"); + return { status: 0 } as never; + }); expect( withFakeOpenshellBinary(() => checkAndRecoverSandboxProcesses("beta", { quiet: true })), @@ -170,6 +178,63 @@ beta 127.0.0.1 18789 12345 running`; ).toBe(false); }); + it("waits for a stopped forward listener to release before starting its replacement", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); + const events: string[] = []; + let staleListenerProbes = 2; + let forwardStarted = false; + + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "1000"); + vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ + status: 0, + output: forwardStarted + ? "SANDBOX BIND PORT PID STATUS\nbeta 127.0.0.1 8642 23456 running" + : "", + })); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation(() => { + const staleListenerReachable = !forwardStarted && staleListenerProbes > 0; + staleListenerProbes -= Number(staleListenerReachable); + forwardStarted || events.push(staleListenerReachable ? "stale-listener" : "released"); + return forwardStarted || staleListenerReachable; + }); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockImplementation((rawArgs: unknown) => { + const args = Array.isArray(rawArgs) ? rawArgs.map(String) : []; + const startingForward = args[0] === "forward" && args[1] === "start"; + startingForward && events.push("start"); + forwardStarted ||= startingForward; + return { status: 0 } as never; + }); + + expect(ensureSandboxPortForwardForPort("beta", 8642)).toBe(true); + expect(events).toEqual(["stale-listener", "stale-listener", "released", "start"]); + expect(runOpenshell).toHaveBeenCalledWith( + ["forward", "start", "--background", "8642", "beta"], + { ignoreError: true }, + ); + }); + + it("fails closed without starting when an unowned stopped-forward listener never releases", () => { + const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); + const forwardHealth = requireSource("../src/lib/actions/sandbox/forward-health.js"); + + vi.stubEnv("NEMOCLAW_FORWARD_RECOVERY_WAIT_MS", "150"); + vi.spyOn(openshellRuntime, "captureOpenshell").mockReturnValue({ status: 0, output: "" }); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); + const runOpenshell = vi + .spyOn(openshellRuntime, "runOpenshell") + .mockReturnValue({ status: 0 } as never); + + expect(ensureSandboxPortForwardForPort("beta", 8642)).toBe(false); + expect( + runOpenshell.mock.calls.some( + ([rawArgs]) => Array.isArray(rawArgs) && rawArgs[0] === "forward" && rawArgs[1] === "start", + ), + ).toBe(false); + }); + it("checkAndRecoverSandboxProcesses re-establishes an active Teams messaging host forward from a compact plan when the dashboard forward is healthy", () => { const openshellRuntime = requireSource("../src/lib/adapters/openshell/runtime.js"); const agentRuntime = requireSource("../src/lib/agent/runtime.js"); @@ -1058,7 +1123,7 @@ hermes-box 127.0.0.1 8642 12346 running`; agent: "hermes", dashboardPort: 18789, }); - vi.spyOn(forwardHealth, "isLocalForwardReachable").mockReturnValue(true); + vi.spyOn(forwardHealth, "isLocalForwardReachable").mockImplementation(() => forwardStarted); vi.spyOn(openshellRuntime, "captureOpenshell").mockImplementation(() => ({ status: 0, output: `SANDBOX BIND PORT PID STATUS\nhermes-box 127.0.0.1 8642 12346 ${forwardStarted ? "running" : "dead"}\nhermes-box 127.0.0.1 18789 12345 running`, diff --git a/test/recover-port-forward.test.ts b/test/recover-port-forward.test.ts index d7d86381482..b020b435f10 100644 --- a/test/recover-port-forward.test.ts +++ b/test/recover-port-forward.test.ts @@ -22,23 +22,46 @@ let nextFixturePort = 47000 + (process.pid % 10000); afterEach(() => { for (const child of listenerProcesses.splice(0)) { - child.kill("SIGTERM"); + child.kill("SIGKILL"); } for (const dir of tmpFixtures.splice(0)) { + const listenerPidFile = path.join(dir, "forward-listener-pids"); + const listenerPids = ( + fs.existsSync(listenerPidFile) ? fs.readFileSync(listenerPidFile, "utf-8") : "" + ) + .split(/\s+/) + .map(Number) + .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid); + for (const pid of listenerPids) { + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe("ESRCH"); + } + } fs.rmSync(dir, { recursive: true, force: true }); } }); -function startReachableForward(port: string): void { - const child = spawn( - process.execPath, - [ - "-e", - `require("node:net").createServer(()=>{}).listen(${JSON.stringify(Number(port))},"127.0.0.1")`, - ], - { stdio: "ignore" }, +function forwardListenerScript(port: string): string { + return ( + 'const net=require("node:net");' + + "const server=net.createServer(()=>{});" + + "let stopping=false;" + + 'process.on("SIGTERM",()=>{' + + "if(stopping)return;" + + "stopping=true;" + + "setTimeout(()=>server.close(()=>process.exit(0)),150);" + + "});" + + `server.listen(${JSON.stringify(Number(port))},"127.0.0.1");` ); +} + +function startReachableForward(port: string, listenerPidFile: string): void { + const child = spawn(process.execPath, ["-e", forwardListenerScript(port)], { stdio: "ignore" }); listenerProcesses.push(child); + expect(child.pid, `test forward listener failed to spawn for ${port}`).toBeDefined(); + fs.appendFileSync(listenerPidFile, `${String(child.pid)}\n`); const probe = "const net=require('node:net');" + @@ -111,18 +134,21 @@ function setupFixture(opts: { const recoveredForwardListBody = `${sandboxName} 127.0.0.1 ${port} 99999 running\n`; const forwardStateFile = path.join(tmpDir, "forward-state"); const forwardPollCountFile = path.join(tmpDir, "forward-poll-count"); + const listenerPidFile = path.join(tmpDir, "forward-listener-pids"); fs.writeFileSync(forwardStateFile, "initial"); fs.writeFileSync(forwardPollCountFile, "0"); + fs.writeFileSync(listenerPidFile, ""); // Fake openshell: emits the requested gateway-probe and forward-list - // shapes, swallows mutating subcommands (forward stop / forward start) - // while logging every invocation so the test can assert the order. The - // forward state flips to "running" after `forward start` to model the - // post-recovery probe. + // shapes while logging every invocation so the test can assert the order. + // A stop signals the preexisting listener, which releases asynchronously; + // a successful start launches a replacement listener before flipping the + // forward state to "running" for the post-recovery probe. fs.writeFileSync( openshellPath, `#!${process.execPath} const fs = require("node:fs"); +const { spawn } = require("node:child_process"); const args = process.argv.slice(2); fs.appendFileSync(${JSON.stringify(invocationLog)}, args.join(" ") + "\\n"); @@ -173,8 +199,33 @@ if (args[0] === "forward" && args[1] === "list") { process.exit(0); } +if (args[0] === "forward" && args[1] === "stop") { + const listenerPids = fs.readFileSync(${JSON.stringify(listenerPidFile)}, "utf-8") + .trim() + .split(/\\s+/) + .map(Number) + .filter((pid) => Number.isInteger(pid) && pid > 0); + const listenerPid = listenerPids.at(-1); + if (listenerPid !== undefined) { + try { + process.kill(listenerPid, "SIGTERM"); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + } + process.exit(0); +} + if (args[0] === "forward" && args[1] === "start") { if (${opts.forwardStartHeals === false ? "false" : "true"}) { + const listener = spawn(process.execPath, ["-e", ${JSON.stringify(forwardListenerScript(port))}], { + detached: true, + stdio: "ignore", + }); + listener.unref(); + if (listener.pid !== undefined) { + fs.appendFileSync(${JSON.stringify(listenerPidFile)}, String(listener.pid) + "\\n"); + } fs.writeFileSync( ${JSON.stringify(forwardStateFile)}, ${opts.forwardStartDelayPolls ? '"pending"' : '"running"'}, @@ -184,7 +235,6 @@ if (args[0] === "forward" && args[1] === "start") { } if (args[0] === "forward") { - // forward stop swallowed; forward state untouched. process.exit(0); } @@ -208,13 +258,13 @@ process.exit(0); // answers. Keep the listener alive in a separate process because runRecover // uses spawnSync and blocks this Vitest worker's event loop. const reachablePorts = opts.forwardStartHeals !== false ? [port] : []; - reachablePorts.forEach(startReachableForward); + reachablePorts.forEach((reachablePort) => startReachableForward(reachablePort, listenerPidFile)); return { tmpDir, sandboxName, invocationLog, - recoveryWaitMs: opts.recoveryWaitMs ?? "0", + recoveryWaitMs: opts.recoveryWaitMs ?? "2000", }; } @@ -297,6 +347,7 @@ describe("nemoclaw recover", () => { gatewayProbe: "RUNNING", forwardListStatus: "dead", forwardStartHeals: false, + recoveryWaitMs: "0", }); const result = runRecover(fixture); expect(result.status).toBe(1); diff --git a/test/runner.test.ts b/test/runner.test.ts index ece5f7bc0c0..31720f74e8d 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -13,14 +13,14 @@ import { redact, runCapture } from "../src/lib/runner"; const runnerPath = path.join(import.meta.dirname, "..", "src", "lib", "runner.ts"); const PINNED_OPEN_SHELL_SHA256 = { - cliDarwinArm64: "1ef9a2b447a35391a6a0f417f4383d99f3e928e443cf86ed190002ec937a8871", - cliLinuxArm64: "b86b33d9e7c960cd04bc99a9539964f1cb84ae4a9886dd437c0566b64e093390", - cliLinuxX64: "b71e3a7fb6973c7c353521f88740885e6e661a199b6355140d45f4f8ab72d716", - gatewayDarwinArm64: "26fa5b4dcb6d2631f7212639d087f37d8b0fc50c6f6cec856e019c22847e5bc9", - gatewayLinuxArm64: "e9b258b3fb38fd68ffc37675efe8a027750087f630cf19ad248e94eff5464091", - gatewayLinuxX64: "85fe7c9d939cb2d32389182e816ac388ee1c95dbf5dae1c3dcd37d5bd979db7d", - sandboxLinuxArm64: "e60dc50524c56460faa8c37617725280a6e1205e73e5cc888b4fd0d148ccb71c", - sandboxLinuxX64: "dbf7fffb285e9ffca7ffd439118b7aadd4e5c4df45c73f0fff89fcca9b19c47d", + cliDarwinArm64: "117b5354cc42d80bc4d5e070ea5ac4e341208ff6d3c29b516d8a9c80e2310f8d", + cliLinuxArm64: "a5ff01a3240d73c72ec1700eda6cc6c752a86cf50c5dd1b5bdc459f544d03045", + cliLinuxX64: "37836c3b50383e03249c5e16512c1806e591fba8451408a84fb2f628ddb318c4", + gatewayDarwinArm64: "8c07362107393eb5f4ae4b9ee9f4257fd53862c51ad8dd96f2fe31bb6d8d7ffb", + gatewayLinuxArm64: "a97dcb3acb04fb2d1170c1a2170228990c2337e25bb8c18817e5a6e952204108", + gatewayLinuxX64: "03225fb9388b682af1a5f1614b26b75f828da6031e3ffc1fd920b6fbe5f70877", + sandboxLinuxArm64: "2cf62cbd651e55d0f8750804e2b4025e0d6c8eea4564c87cda47a2c922941db0", + sandboxLinuxX64: "811f914b6a6a3a3f4533449ddebebb6422333861a27a5fa848db6cbfdffdd230", }; type SpawnCallOptions = { diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index a41da885f26..b09b16bdec0 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -78,6 +78,9 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "openclaw-config-guard.py")); writeFixture(path.join("scripts", "codex-acp-wrapper.sh")); writeFixture(path.join("scripts", "generate-openclaw-config.mts")); + writeFixture( + path.join("scripts", "checks", "verify-openshell-policy-boundary-dependencies.mts"), + ); writeFixture(path.join("scripts", "lib", "sandbox-init.sh")); writeFixture(path.join("scripts", "lib", "gateway-supervisor.sh")); writeFixture(path.join("scripts", "lib", "sandbox-rlimits.sh")); diff --git a/test/support/openshell-gateway-config-helpers.ts b/test/support/openshell-gateway-config-helpers.ts index 266d274a9cd..a75b0741fb7 100644 --- a/test/support/openshell-gateway-config-helpers.ts +++ b/test/support/openshell-gateway-config-helpers.ts @@ -24,7 +24,7 @@ export const GATEWAY_AUTH_REVIEW_NOTE = path.join( REPO_ROOT, "docs", "security", - "openshell-0.0.71-gateway-auth-review.mdx", + "openshell-0.0.72-compatibility-review.mdx", ); const SANDBOX_JWT_SUBJECT_PREFIX = "spiffe://openshell/sandbox/"; @@ -39,7 +39,8 @@ export function baseGatewayEnv(stateDir: string): Record { OPENSHELL_GRPC_ENDPOINT: "https://127.0.0.1:8080", OPENSHELL_LOCAL_TLS_DIR: path.join(stateDir, "tls"), OPENSHELL_DOCKER_NETWORK_NAME: "openshell-docker", - OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "ghcr.io/nvidia/openshell/supervisor:0.0.71", + OPENSHELL_DOCKER_SUPERVISOR_IMAGE: + "ghcr.io/nvidia/openshell/supervisor@sha256:80ed9cda5bf672fefdb9dcd4604b40a8b09c0891b6eb9d03e10227c7e3dfb49d", }; } diff --git a/vitest.config.ts b/vitest.config.ts index 221cfeab19c..15d8683b28b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,20 @@ const runLiveE2E = shouldRunLiveE2E(); const runBranchValidationE2E = shouldRunBranchValidationE2E(); const e2eRetryCount = resolveE2ERetryCount(); const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); +const canonicalOpenShellPolicyBoundary = path.resolve( + "nemoclaw/src/shared/openshell-policy-boundary.cts", +); +const canonicalOpenShellPolicyAlias = [ + { + find: /^.*openshell-policy-boundary\.cjs$/, + replacement: canonicalOpenShellPolicyBoundary, + }, +]; +const typedSourceTransform = { + oxc: { + include: /\.(?:[cm]?ts|[jt]sx)$/, + }, +}; const sourceNodeOptions = [process.env.NODE_OPTIONS, `--require=${sourceRequireHook}`] .filter(Boolean) .join(" "); @@ -35,8 +49,10 @@ export default defineConfig({ hideSkippedTests: isCi, projects: [ { + ...typedSourceTransform, test: { name: "cli", + alias: canonicalOpenShellPolicyAlias, testTimeout: testTimeout(), setupFiles: ["test/helpers/onboard-script-mocks.cjs"], include: ["src/**/*.test.ts"], @@ -44,8 +60,10 @@ export default defineConfig({ }, }, { + ...typedSourceTransform, test: { name: "integration", + alias: canonicalOpenShellPolicyAlias, // Source-backed process fixtures can exceed the unit-test budget // when several coverage shards transpile and spawn them concurrently. testTimeout: testTimeout(15_000), @@ -77,8 +95,10 @@ export default defineConfig({ }, }, { + ...typedSourceTransform, test: { name: "installer-integration", + alias: canonicalOpenShellPolicyAlias, include: [ "test/install-express-prompt.test.ts", "test/install-preflight.test.ts", @@ -90,29 +110,38 @@ export default defineConfig({ }, }, { + ...typedSourceTransform, test: { name: "package-contract", + alias: canonicalOpenShellPolicyAlias, include: ["test/package-contract/**/*.test.ts"], }, }, { + ...typedSourceTransform, test: { name: "plugin", + alias: canonicalOpenShellPolicyAlias, include: ["nemoclaw/src/**/*.test.ts"], }, }, { + ...typedSourceTransform, test: { // Fast tests for the E2E fixture/support layer. Vitest remains the // only harness; this project does not define a separate runner. name: "e2e-support", + alias: canonicalOpenShellPolicyAlias, testTimeout: testTimeout(), + setupFiles: ["test/helpers/onboard-script-mocks.cjs"], include: ["test/e2e/support/**/*.test.ts"], }, }, { + ...typedSourceTransform, test: { name: "e2e-live", + alias: canonicalOpenShellPolicyAlias, testTimeout: testTimeout(LIVE_E2E_PROJECT_TIMEOUT_MS), // Vitest counts retries after the initial failure. In CI the default // value of 2 gives live E2Es up to three total attempts while keeping @@ -125,8 +154,10 @@ export default defineConfig({ }, }, { + ...typedSourceTransform, test: { name: "e2e-branch-validation", + alias: canonicalOpenShellPolicyAlias, retry: e2eRetryCount, include: runBranchValidationE2E ? ["test/e2e/brev-e2e.test.ts"] : [], // Branch validation E2E: rsyncs the branch over a Brev instance @@ -148,7 +179,7 @@ export default defineConfig({ ], coverage: { provider: "v8", - include: ["src/**/*.ts", "bin/**/*.js", "nemoclaw/src/**/*.ts"], + include: ["src/**/*.ts", "bin/**/*.js", "nemoclaw/src/**/*.ts", "nemoclaw/src/**/*.cts"], exclude: ["**/*.test.ts", "dist/**"], reporter: ["text-summary", "json-summary"], },