diff --git a/.github/workflows/e2e-standard-profile.yaml b/.github/workflows/e2e-standard-profile.yaml index 0516d7d15bb..13dd1fb66b0 100644 --- a/.github/workflows/e2e-standard-profile.yaml +++ b/.github/workflows/e2e-standard-profile.yaml @@ -27,6 +27,9 @@ on: managed_image_revision: required: true type: string + managed_image_receipt: + required: true + type: string credential_boundary: required: true type: string @@ -113,6 +116,7 @@ jobs: E2E_TARGET_ID: ${{ inputs.target_id }} NEMOCLAW_RUN_LIVE_E2E: "1" NEMOCLAW_E2E_EXPECTED_SHA: ${{ inputs.candidate_sha }} + E2E_MANAGED_IMAGE_COHORT_RECEIPT: ${{ inputs.managed_image_receipt }} NEMOCLAW_E2E_CORRELATION_ID: ${{ inputs.risk_signal_correlation_id }} NEMOCLAW_E2E_RISK_SIGNAL_EXPECTED_SHA: ${{ inputs.risk_signal_expected_sha }} NEMOCLAW_LLAMA_CPP_QUALIFICATION_HEAD_SHA: ${{ inputs.candidate_sha }} diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 3da6a3d3134..f8c306d17fc 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -96,13 +96,17 @@ env: jobs: base-image-publication: - needs: generate-matrix runs-on: ubuntu-latest timeout-minutes: 55 outputs: - dcode_base_contract: ${{ steps.validate_dcode_base.outputs.contract || steps.validate_reused_dcode_base.outputs.contract }} - dcode_base_ref: ${{ steps.validate_dcode_base.outputs.base_ref || steps.validate_reused_dcode_base.outputs.base_ref }} - managed_image_revision: ${{ steps.publication.outputs.head_sha || (steps.publication_mode.outputs.reuse == '1' && 'e38db201413b457614904187377ed9fd002d281d') || inputs.checkout_sha || github.sha }} + dcode_base_contract: ${{ steps.validate_dcode_base.outputs.contract }} + dcode_base_ref: ${{ steps.validate_dcode_base.outputs.base_ref }} + managed_image_artifact_provenance: ${{ steps.download_managed_cohort.outputs.provenance }} + managed_image_cohort: ${{ steps.validate_managed_cohort.outputs.cohort }} + managed_image_receipt: ${{ steps.validate_managed_cohort.outputs.receipt }} + managed_image_revision: ${{ steps.validate_managed_cohort.outputs.revision }} + managed_image_run_attempt: ${{ steps.validate_managed_cohort.outputs.run_attempt }} + managed_image_run_id: ${{ steps.validate_managed_cohort.outputs.run_id }} permissions: actions: read contents: read @@ -111,69 +115,70 @@ jobs: name: Classify base-image publication requirement env: CHECKOUT_SHA: ${{ inputs.checkout_sha }} + BASE_SHA: ${{ inputs.base_sha }} EVENT_NAME: ${{ github.event_name }} REF: ${{ github.ref }} REPOSITORY: ${{ github.repository }} + WORKFLOW_SHA: ${{ github.workflow_sha }} shell: bash run: | set -euo pipefail - reuse=0 case "${REPOSITORY}:${REF}:${EVENT_NAME}:${CHECKOUT_SHA:+controller}" in NVIDIA/NemoClaw:refs/heads/main:push:|NVIDIA/NemoClaw:refs/heads/main:workflow_dispatch:) - required=1 + expected_sha="$WORKFLOW_SHA" + allow_non_head=0 + select_nearest_successful=0 ;; NVIDIA/NemoClaw:refs/heads/*:workflow_dispatch:controller) - required=0 - reuse=1 + [[ "$BASE_SHA" =~ ^[a-f0-9]{40}$ ]] || { + echo "::error::manual PR publication selection requires an exact base SHA" >&2 + exit 1 + } + expected_sha="$BASE_SHA" + allow_non_head=1 + select_nearest_successful=1 ;; *) echo "::error::base-image publication mode is not trusted" >&2 exit 1 ;; esac - printf 'required=%s\n' "${required}" >> "${GITHUB_OUTPUT}" - printf 'reuse=%s\n' "${reuse}" >> "${GITHUB_OUTPUT}" + printf 'allow_non_head=%s\n' "${allow_non_head}" >> "${GITHUB_OUTPUT}" + printf 'expected_sha=%s\n' "${expected_sha}" >> "${GITHUB_OUTPUT}" + printf 'select_nearest_successful=%s\n' "${select_nearest_successful}" >> "${GITHUB_OUTPUT}" - name: Check out trusted E2E workflow - if: ${{ steps.publication_mode.outputs.required == '1' || steps.publication_mode.outputs.reuse == '1' }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.workflow_sha || github.workflow_sha }} + ref: ${{ github.workflow_sha }} fetch-depth: 0 persist-credentials: false - name: Set up Node for publication verification - if: ${{ steps.publication_mode.outputs.required == '1' || steps.publication_mode.outputs.reuse == '1' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 - - id: pr_dcode_base - name: Resolve Deep Agents Code base publication for the exact PR managed image - if: ${{ steps.publication_mode.outputs.reuse == '1' && needs.generate-matrix.outputs.managed_image_catalog != '' }} - env: - CANDIDATE_REPOSITORY: ${{ inputs.checkout_repository }} - CANDIDATE_SHA: ${{ inputs.checkout_sha }} - GITHUB_TOKEN: ${{ github.token }} - MANAGED_IMAGE_CATALOG: ${{ needs.generate-matrix.outputs.managed_image_catalog }} - run: node --experimental-strip-types --no-warnings tools/e2e/pr-dcode-base-publication.mts - - id: publication - name: Verify applicable base-image publication - if: ${{ steps.publication_mode.outputs.required == '1' }} + name: Select complete base and managed-image publication env: - EXPECTED_SHA: ${{ inputs.checkout_sha || github.sha }} + EXPECTED_SHA: ${{ steps.publication_mode.outputs.expected_sha }} GITHUB_TOKEN: ${{ github.token }} + PUBLICATION_HISTORY_ALLOW_NON_HEAD: ${{ steps.publication_mode.outputs.allow_non_head }} REQUIRE_MANAGED_IMAGE_PUBLICATION: "1" + SELECT_NEAREST_SUCCESSFUL_PUBLICATION: ${{ steps.publication_mode.outputs.select_nearest_successful }} shell: bash run: | set -euo pipefail export GITHUB_REF=refs/heads/main export GITHUB_SHA="$EXPECTED_SHA" - node --experimental-strip-types --no-warnings tools/e2e/base-image-publication.mts --wait-seconds 3000 --poll-seconds 30 + wait_seconds=3000 + if [[ "$SELECT_NEAREST_SUCCESSFUL_PUBLICATION" == "1" ]]; then + wait_seconds=300 + fi + node --experimental-strip-types --no-warnings tools/e2e/base-image-publication.mts --wait-seconds "$wait_seconds" --poll-seconds 30 - name: Download immutable Deep Agents Code base contract - if: ${{ steps.publication_mode.outputs.required == '1' }} env: GITHUB_TOKEN: ${{ github.token }} PUBLICATION_HEAD_SHA: ${{ steps.publication.outputs.head_sha }} @@ -181,35 +186,34 @@ jobs: PUBLICATION_RUN_ID: ${{ steps.publication.outputs.run_id }} run: node --experimental-strip-types --no-warnings tools/e2e/exact-artifact-download.mts "${RUNNER_TEMP}/dcode-base-contract" - - name: Download reused Deep Agents Code base contract - if: ${{ steps.publication_mode.outputs.reuse == '1' }} - env: - GITHUB_TOKEN: ${{ github.token }} - PUBLICATION_HEAD_SHA: ${{ steps.pr_dcode_base.outputs.head_sha || 'e38db201413b457614904187377ed9fd002d281d' }} - PUBLICATION_RUN_ATTEMPT: ${{ steps.pr_dcode_base.outputs.run_attempt || '1' }} - PUBLICATION_RUN_ID: ${{ steps.pr_dcode_base.outputs.run_id || '32544159037' }} - run: node --experimental-strip-types --no-warnings tools/e2e/exact-artifact-download.mts "${RUNNER_TEMP}/dcode-base-contract-reused" - - id: validate_dcode_base name: Validate immutable Deep Agents Code base - if: ${{ steps.publication_mode.outputs.required == '1' }} env: PUBLICATION_HEAD_SHA: ${{ steps.publication.outputs.head_sha }} PUBLICATION_RUN_ATTEMPT: ${{ steps.publication.outputs.run_attempt }} PUBLICATION_RUN_ID: ${{ steps.publication.outputs.run_id }} run: node --experimental-strip-types --no-warnings tools/e2e/dcode-base-image-contract.mts "${RUNNER_TEMP}/dcode-base-contract/contract.json" - - id: validate_reused_dcode_base - name: Validate reused Deep Agents Code base - if: ${{ steps.publication_mode.outputs.reuse == '1' }} + - id: download_managed_cohort + name: Download immutable managed-image cohort contract env: - EXPECTED_BASE_REF: ${{ steps.pr_dcode_base.outputs.base_ref }} - PUBLICATION_HEAD_SHA: ${{ steps.pr_dcode_base.outputs.head_sha || 'e38db201413b457614904187377ed9fd002d281d' }} - PUBLICATION_RUN_ATTEMPT: ${{ steps.pr_dcode_base.outputs.run_attempt || '1' }} - PUBLICATION_RUN_ID: ${{ steps.pr_dcode_base.outputs.run_id || '32544159037' }} - run: node --experimental-strip-types --no-warnings tools/e2e/dcode-base-image-contract.mts "${RUNNER_TEMP}/dcode-base-contract-reused/contract.json" + GITHUB_TOKEN: ${{ github.token }} + PUBLICATION_ARTIFACT_KIND: managed-image-cohort + PUBLICATION_HEAD_SHA: ${{ steps.publication.outputs.head_sha }} + PUBLICATION_RUN_ATTEMPT: ${{ steps.publication.outputs.run_attempt }} + PUBLICATION_RUN_ID: ${{ steps.publication.outputs.run_id }} + run: node --experimental-strip-types --no-warnings tools/e2e/exact-artifact-download.mts "${RUNNER_TEMP}/managed-image-cohort" + + - id: validate_managed_cohort + name: Validate immutable managed-image cohort contract + env: + PUBLICATION_HEAD_SHA: ${{ steps.publication.outputs.head_sha }} + PUBLICATION_RUN_ATTEMPT: ${{ steps.publication.outputs.run_attempt }} + PUBLICATION_RUN_ID: ${{ steps.publication.outputs.run_id }} + run: node --experimental-strip-types --no-warnings tools/e2e/managed-image-cohort-contract.mts "${RUNNER_TEMP}/managed-image-cohort/cohort.json" generate-matrix: + needs: base-image-publication runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -218,7 +222,6 @@ jobs: pull-requests: read outputs: cli_artifact_provenance: ${{ steps.record_cli_artifact.outputs.provenance }} - managed_image_catalog: ${{ steps.package_cli_artifact.outputs.managed_image_catalog }} e2e_credentials_allowed: ${{ steps.e2e_credentials.outputs.allowed }} matrix: ${{ steps.matrix.outputs.matrix }} test_matrix: ${{ steps.matrix.outputs.test_matrix }} @@ -584,16 +587,6 @@ jobs: fi fi - - name: Resolve exact PR managed-image catalog - if: ${{ inputs.checkout_sha != '' && (inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '') }} - env: - BASE_SHA: ${{ inputs.base_sha }} - CANDIDATE_REPOSITORY: ${{ inputs.checkout_repository }} - CANDIDATE_SHA: ${{ inputs.checkout_sha }} - GITHUB_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ inputs.pr_number }} - run: node --experimental-strip-types --no-warnings tools/e2e/pr-managed-image-publication.mts "${RUNNER_TEMP}/pr-managed-image-catalog.json" - - name: Check out E2E candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 if: ${{ inputs.checkout_sha == '' || inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '' }} @@ -708,21 +701,6 @@ jobs: ' dist/build-identity.json >/dev/null || { echo "::error::candidate CLI build identity does not match the candidate commit SHA"; exit 1; } - managed_catalog="${RUNNER_TEMP}/pr-managed-image-catalog.json" - rm -f -- dist/e2e-managed-image-catalog.json - if [[ -e "$managed_catalog" ]]; then - [[ -f "$managed_catalog" && ! -L "$managed_catalog" && -s "$managed_catalog" ]] || - { echo "::error::trusted PR managed-image catalog is not a nonempty regular file"; exit 1; } - candidate_release="v$(jq -r '.nemoclawVersion' dist/build-identity.json)" - jq -e --arg release "$candidate_release" --arg revision "$CANDIDATE_SHA" ' - type == "object" and length > 0 and - all(.[]; .source.revision == $revision and .source.release == $release) - ' "$managed_catalog" >/dev/null || - { echo "::error::managed-image catalog source identity does not match the candidate"; exit 1; } - install -m 0600 "$managed_catalog" dist/e2e-managed-image-catalog.json - printf 'managed_image_catalog=%s\n' "$(jq -c . "$managed_catalog")" >>"$GITHUB_OUTPUT" - fi - artifact_dir="${RUNNER_TEMP}/nemoclaw-cli-artifact" install -d -m 0700 "$artifact_dir" payload="$artifact_dir/nemoclaw-cli.tar" @@ -2717,6 +2695,7 @@ jobs: env: E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live E2E_MANAGED_IMAGE_REVISION: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + E2E_MANAGED_IMAGE_COHORT_RECEIPT: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} NEMOCLAW_LANGCHAIN_DEEPAGENTS_CODE_SANDBOX_BASE_IMAGE_REF: ${{ needs.base-image-publication.outputs.dcode_base_ref }} NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js NEMOCLAW_RUN_LIVE_E2E: "1" @@ -2831,7 +2810,7 @@ jobs: - name: Run live E2E tests env: E2E_TARGET_ID: ${{ matrix.id }} - NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} + NVIDIA_INFERENCE_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_INFERENCE_API_KEY || '' }} TARGET_ID: ${{ matrix.id }} run: | set -euo pipefail @@ -3008,6 +2987,7 @@ jobs: cli_artifact_provenance: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} managed_image_catalog: ${{ needs.generate-matrix.outputs.managed_image_catalog }} managed_image_revision: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + managed_image_receipt: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} credential_boundary: no provider credential target_id: ${{ matrix.target_id }} catalogue_id: ${{ matrix.id }} @@ -3049,6 +3029,7 @@ jobs: cli_artifact_provenance: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} managed_image_catalog: ${{ needs.generate-matrix.outputs.managed_image_catalog }} managed_image_revision: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + managed_image_receipt: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} credential_boundary: NVIDIA API key target_id: ${{ matrix.target_id }} catalogue_id: ${{ matrix.id }} @@ -3091,6 +3072,7 @@ jobs: cli_artifact_provenance: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} managed_image_catalog: ${{ needs.generate-matrix.outputs.managed_image_catalog }} managed_image_revision: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + managed_image_receipt: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} credential_boundary: NVIDIA inference API key target_id: ${{ matrix.target_id }} catalogue_id: ${{ matrix.id }} @@ -3133,6 +3115,7 @@ jobs: cli_artifact_provenance: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} managed_image_catalog: ${{ needs.generate-matrix.outputs.managed_image_catalog }} managed_image_revision: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + managed_image_receipt: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} credential_boundary: GitHub read token target_id: ${{ matrix.target_id }} catalogue_id: ${{ matrix.id }} @@ -3175,6 +3158,7 @@ jobs: cli_artifact_provenance: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} managed_image_catalog: ${{ needs.generate-matrix.outputs.managed_image_catalog }} managed_image_revision: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + managed_image_receipt: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} credential_boundary: Brave and NVIDIA inference API keys target_id: ${{ matrix.target_id }} catalogue_id: ${{ matrix.id }} @@ -3280,7 +3264,7 @@ jobs: run: bash .github/scripts/docker-auth-cleanup.sh mcp-bridge: - needs: generate-matrix + needs: [base-image-publication, generate-matrix] if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'mcp-bridge') }} runs-on: ${{ fromJSON(needs.generate-matrix.outputs.runner_routing)[format('mcp-bridge-{0}', matrix.agent)] }} permissions: @@ -3303,6 +3287,8 @@ jobs: agent_runtime: langchain-deepagents-code coverage_variant: deepagents env: + E2E_MANAGED_IMAGE_REVISION: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + E2E_MANAGED_IMAGE_COHORT_RECEIPT: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} E2E_JOB: "1" E2E_TARGET_ID: "mcp-bridge" E2E_OBSERVABLE_OUTCOME: "Stable OpenShell MCP bridge reaches tools and inference" @@ -3625,7 +3611,7 @@ jobs: run: bash .github/scripts/docker-auth-cleanup.sh openshell-credential-generation-window: - needs: generate-matrix + needs: [base-image-publication, generate-matrix] if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'openshell-credential-generation-window') }} runs-on: ubuntu-latest permissions: @@ -3635,6 +3621,8 @@ jobs: # MCP lifecycle without sharing destructive sandbox state. timeout-minutes: 90 env: + E2E_MANAGED_IMAGE_REVISION: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + E2E_MANAGED_IMAGE_COHORT_RECEIPT: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} E2E_JOB: "1" E2E_TARGET_ID: "openshell-credential-generation-window" E2E_AGENT_RUNTIME: "openclaw" @@ -3786,7 +3774,7 @@ jobs: path: ${{ runner.temp }}/openshell-dev-artifact/ mcp-bridge-dev: - needs: [generate-matrix, openshell-dev-artifact] + needs: [base-image-publication, generate-matrix, openshell-dev-artifact] if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'mcp-bridge-dev') }} runs-on: ubuntu-latest permissions: @@ -3807,6 +3795,8 @@ jobs: agent_runtime: langchain-deepagents-code coverage_variant: deepagents env: + E2E_MANAGED_IMAGE_REVISION: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + E2E_MANAGED_IMAGE_COHORT_RECEIPT: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} E2E_JOB: "1" E2E_TARGET_ID: "mcp-bridge-dev" E2E_OBSERVABLE_OUTCOME: "Development OpenShell MCP bridge reaches tools and inference" @@ -4687,7 +4677,6 @@ jobs: E2E_AGENT_RUNTIME: "openclaw + hermes + langchain-deepagents-code" E2E_OBSERVABLE_OUTCOME: "Protected GPU runtime supports Ollama vLLM NIM rollback and cleanup" E2E_ENVIRONMENT_OR_INFERENCE_ENDPOINT: "NVIDIA GPU runner; local and hosted inference services" - E2E_WORKLOAD_SOURCE: "managed-image" RELEASE_E2E_ACTIVATION_PATH: ci/protected-managed-image-runtime-activation-v1.json NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js NEMOCLAW_E2E_EXPECTED_SHA: ${{ inputs.checkout_sha }} @@ -4971,11 +4960,13 @@ jobs: run: bash .github/scripts/docker-auth-cleanup.sh hermes-e2e: - needs: generate-matrix + needs: [base-image-publication, generate-matrix] if: ${{ needs.generate-matrix.outputs.hermes_selected == 'true' }} runs-on: ${{ fromJSON(needs.generate-matrix.outputs.runner_routing)['hermes-e2e'] }} timeout-minutes: 85 env: + E2E_MANAGED_IMAGE_REVISION: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + E2E_MANAGED_IMAGE_COHORT_RECEIPT: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} E2E_JOB: "1" E2E_TARGET_ID: "hermes-e2e" E2E_AGENT_RUNTIME: "hermes" @@ -5061,7 +5052,7 @@ jobs: run: bash .github/scripts/docker-auth-cleanup.sh hermes-gpu-startup: - needs: generate-matrix + needs: [base-image-publication, generate-matrix] if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'hermes-gpu-startup') }} runs-on: linux-amd64-gpu-rtxpro6000-latest-1 timeout-minutes: 90 @@ -5083,6 +5074,8 @@ jobs: observable_outcome: "Compatibility-only GPU startup reaches the stable Ready route" coverage_variant: compatibility-only env: + E2E_MANAGED_IMAGE_REVISION: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + E2E_MANAGED_IMAGE_COHORT_RECEIPT: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} E2E_JOB: "1" E2E_TARGET_ID: "hermes-gpu-startup" E2E_AGENT_RUNTIME: "hermes" @@ -5387,13 +5380,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 70 env: + E2E_MANAGED_IMAGE_REVISION: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + E2E_MANAGED_IMAGE_COHORT_RECEIPT: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} E2E_JOB: "1" E2E_TARGET_ID: "cloud-onboard" E2E_AGENT_RUNTIME: "openclaw" E2E_OBSERVABLE_OUTCOME: "Public install onboarding hosted inference and security checks succeed" E2E_ENVIRONMENT_OR_INFERENCE_ENDPOINT: "Ubuntu; NVIDIA hosted inference" E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/cloud-onboard - E2E_MANAGED_IMAGE_REVISION: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js NEMOCLAW_RUN_LIVE_E2E: "1" NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1" @@ -5444,30 +5438,6 @@ jobs: with: provenance-json: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} - - name: Materialize cloud-onboard managed-image catalog - if: ${{ needs.generate-matrix.outputs.managed_image_catalog != '' }} - env: - CANDIDATE_SHA: ${{ inputs.checkout_sha || github.sha }} - MANAGED_IMAGE_CATALOG: ${{ needs.generate-matrix.outputs.managed_image_catalog }} - shell: bash - run: | - set -euo pipefail - candidate_release="v$(jq -r '.nemoclawVersion' dist/build-identity.json)" - catalog_path="${RUNNER_TEMP}/e2e-managed-image-catalog.json" - jq -e --arg release "$candidate_release" --arg revision "$CANDIDATE_SHA" ' - type == "object" and length > 0 and - all(.[]; .source.revision == $revision and .source.release == $release) - ' <<<"$MANAGED_IMAGE_CATALOG" >/dev/null || { - echo "::error::managed-image catalog source identity does not match the candidate" >&2 - exit 1 - } - jq -c . <<<"$MANAGED_IMAGE_CATALOG" >"$catalog_path" - [[ -s "$catalog_path" && ! -L "$catalog_path" ]] || { - echo "::error::temporary managed-image catalog is invalid" >&2 - exit 1 - } - printf 'NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG=%s\n' "$catalog_path" >>"$GITHUB_ENV" - - name: Install OpenShell CLI run: bash scripts/install-openshell.sh @@ -5531,11 +5501,13 @@ jobs: run: bash .github/scripts/docker-auth-cleanup.sh messaging-providers: - needs: generate-matrix + needs: [base-image-publication, generate-matrix] if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'messaging-providers') }} runs-on: ubuntu-latest timeout-minutes: 90 env: + E2E_MANAGED_IMAGE_REVISION: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }} + E2E_MANAGED_IMAGE_COHORT_RECEIPT: ${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }} E2E_JOB: "1" E2E_TARGET_ID: "messaging-providers" E2E_AGENT_RUNTIME: "openclaw" diff --git a/.github/workflows/pr-self-hosted.yaml b/.github/workflows/pr-self-hosted.yaml index c07f89098d9..c7e2ff48b84 100644 --- a/.github/workflows/pr-self-hosted.yaml +++ b/.github/workflows/pr-self-hosted.yaml @@ -90,7 +90,7 @@ jobs: printf 'selected=%s\n' "$selected" } >>"$GITHUB_OUTPUT" - - name: Check out trusted publication gate + - name: Check out PR base SHA for publication verification if: ${{ steps.changed.outputs.selected == 'true' }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -104,12 +104,12 @@ jobs: with: node-version: 22 - - name: Install trusted publication gate dependencies + - name: Install publication verifier dependencies if: ${{ steps.changed.outputs.selected == 'true' }} run: npm ci --ignore-scripts --no-audit --no-fund - id: publication - name: Verify applicable base-image publication + name: Verify complete base and managed-image publication if: ${{ steps.changed.outputs.selected == 'true' }} env: EXPECTED_SHA: ${{ steps.changed.outputs.base_sha }} diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index cafe3abc025..53535f150cf 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -81,6 +81,11 @@ "test": "selects exact-commit rootless evidence for Portable recovery changes (#9707)", "category": "security" }, + { + "file": "test/e2e/support/pr-self-hosted-llama-selector.test.ts", + "test": "binds trusted base publication to the generic NVIDIA GPU job", + "category": "security" + }, { "file": "test/agents/hermes/hermes-final-image-layout.test.ts", "test": "pins $source to its current bytes at $target", diff --git a/docs/deployment/sandbox-hardening.mdx b/docs/deployment/sandbox-hardening.mdx index b3a2fe9bd5a..2640c721333 100644 --- a/docs/deployment/sandbox-hardening.mdx +++ b/docs/deployment/sandbox-hardening.mdx @@ -15,7 +15,7 @@ The NemoClaw sandbox image applies several security measures to reduce the attac ## Immutable Managed Image Selection Stock onboarding through the OpenShell Docker driver selects an exact managed-image digest and validates the complete OpenClaw, Hermes, and LangChain Deep Agents Code publication cohort before sandbox creation. -If registry or catalog availability prevents resolution, the ordinary `prefer-managed` path builds the shipped, reviewed repository Dockerfile instead; it never selects an unpinned `:latest` image. +If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. An available but incomplete, mixed, mutable, wrong-platform, or identity-inconsistent cohort fails closed before sandbox creation. Passing `--from ` remains a separate explicit opt-in whose complete custom image must be reviewed independently. diff --git a/docs/get-started/quickstart-hermes.mdx b/docs/get-started/quickstart-hermes.mdx index 61293de4490..eb1361c6538 100644 --- a/docs/get-started/quickstart-hermes.mdx +++ b/docs/get-started/quickstart-hermes.mdx @@ -62,8 +62,9 @@ Review the [Prerequisites](prerequisites) before you begin. If the installer does not offer Express setup, or if you enter `n` at the Express prompt on a supported non-N1x host, choose an inference provider and model, then provide its credential when prompted. For that interactive path, skip optional web search and messaging setup on a first run, then accept the suggested network policy tier. With the OpenShell Docker driver, stock Hermes onboarding normally uses the release's exact managed-image digest. - If registry or catalog availability prevents resolution, it builds the shipped repository Dockerfile instead; it never selects an unpinned `:latest` image. + If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Invalid or inconsistent catalog evidence fails closed before sandbox creation. + An explicit `nemohermes onboard --from ` remains a separate custom-image path. diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 45167aa3a80..c553707e0f5 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -65,8 +65,9 @@ Review the [Prerequisites](prerequisites) before you begin. If the installer does not offer Express setup, or if you enter `n` at the Express prompt on a supported non-N1x host, choose an inference provider and model, then provide its credential when prompted. For that interactive path, accept the suggested network policy tier on a first run. With the OpenShell Docker driver, stock Deep Agents Code onboarding normally uses the release's exact managed-image digest. - If registry or catalog availability prevents resolution, it builds the shipped repository Dockerfile instead; it never selects an unpinned `:latest` image. + If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Invalid or inconsistent catalog evidence fails closed before sandbox creation. + An explicit `nemo-deepagents onboard --from ` remains a separate custom-image path. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index c085d59a8da..3c5479d1607 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -51,8 +51,9 @@ Review the [Prerequisites](prerequisites) before you begin. Press Enter to accept the suggested `my-assistant` sandbox name. For a first run, skip optional web search and messaging setup, then accept the suggested network policy tier. With the OpenShell Docker driver, stock OpenClaw onboarding normally uses the release's exact managed-image digest. - If registry or catalog availability prevents resolution, it builds the shipped repository Dockerfile instead; it never selects an unpinned `:latest` image. + If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Invalid or inconsistent catalog evidence fails closed before sandbox creation. + An explicit `nemoclaw onboard --from ` remains a separate custom-image path. The installer can display `Run express install with these settings? [Y/n]:` before the agent-selection prompt on DGX Spark, qualifying DGX Station, or Windows Subsystem for Linux (WSL) hosts. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index beb1443949d..a7d0755f22e 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -312,7 +312,7 @@ The maintained onboarding path for this agent does not consume the component. Stock onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. Before selecting one agent image, NemoClaw validates a complete three-agent cohort with one release, source revision, publication cohort, and compatible startup and capability contracts. -If registry or catalog availability prevents resolution, the ordinary `prefer-managed` path builds the shipped, reviewed repository Dockerfile instead and never selects an unpinned tag. +If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. Available catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent fails closed before sandbox creation. An explicit `--from ` remains a separate complete custom-image path. The portable experimental profile retains its existing workload path, and native Podman remains disabled. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 9a6bc8c912f..03ae70a7928 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -977,8 +977,8 @@ Docker and Podman gateways cannot reuse one state directory. For NemoClaw-manage Without `--from`, onboarding through the OpenShell Docker driver for OpenClaw, Hermes, and LangChain Deep Agents Code selects an immutable managed image for the installed release and host architecture. NemoClaw validates the complete three-agent publication cohort before selecting any member. -If registry or catalog availability prevents resolution, the ordinary `prefer-managed` path builds the shipped, reviewed repository Dockerfile instead; it never selects an unpinned `:latest` image. -Available catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent fails closed before sandbox creation. +If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile. +Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent also fails closed before sandbox creation. The portable experimental profile and native Podman are not part of this activation. Build the sandbox image from a custom Dockerfile instead of the stock NemoClaw image. diff --git a/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts b/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts index 0d58390399d..8309afbbf5e 100644 --- a/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts +++ b/src/lib/onboard/machine/handlers/provider-inference-host-local-startup.test.ts @@ -26,7 +26,6 @@ import { } from "./provider-inference.test-support"; type TestProviderInferenceOptions = ProviderInferenceStateOptions; - const PROBE_IMAGE = `quay.io/curl/curl@sha256:${"b".repeat(64)}`; const NIM_IMAGE = `nvcr.io/nim/meta/llama@sha256:${"d".repeat(64)}`; const MANAGED_IMAGE = `nvcr.io/nvidia/vllm@sha256:${"c".repeat(64)}`; @@ -39,7 +38,6 @@ const receiptWriter = { targetSha256: "1".repeat(64), writeExact: (value: string) => value, }; - function publishedManagedReceipt( service: "nim" | "vllm", model: string, @@ -245,6 +243,25 @@ function llamaCppLifecycleSelection( } describe("provider inference host-local startup selection", () => { + it("does not require host-local application support for a hosted candidate provider", () => { + const resolver = vi.fn(); + const resolve = createCachedHostLocalInferenceSetupResolver({ + resolver, + application: "pi", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + acceleration: "cpu", + requireToolCalling: null, + freshRequireToolCalling: true, + allowPublishedResume: false, + recover: false, + recordToolCallingRequirement: vi.fn(), + }); + + expect(resolve("pi-sandbox")).toEqual({}); + expect(resolver).not.toHaveBeenCalled(); + }); + it.each(["openclaw", "hermes", "langchain-deepagents-code"] as const)( "dispatches a published %s llama.cpp route through the common lifecycle exactly once", async (application) => { diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index f141f0c9ef7..246ceb32fb7 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -546,6 +546,7 @@ function hostLocalInferenceSetupOptions( (candidate): candidate is HostLocalInferenceApplication => candidate === input.application, ); if (!application) { + if (!isHostLocalInferenceProvider(input.provider)) return {}; throw new Error(`Unsupported host-local inference application '${input.application}'.`); } const selected = resolver({ diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.test.ts b/src/lib/onboard/managed-workload/onboard-orchestration.test.ts index 73ddb90eacc..250b3bd0c5f 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.test.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; import { createHermesStateVolumeDockerHarness } from "../__test-helpers__/hermes-state-volume"; @@ -14,6 +14,9 @@ const preparationState = vi.hoisted(() => ({ useUnavailableCatalog: false, })); const prepareSandboxWorkloadSource = vi.hoisted(() => vi.fn()); +const INSTALLED_REVISION = vi.hoisted(() => "d".repeat(40)); +const releaseRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-release-root-")); +fs.writeFileSync(path.join(releaseRoot, ".version"), "0.0.0\n"); vi.mock("../workload/preparation", async (importOriginal) => { const original = await importOriginal(); @@ -30,7 +33,10 @@ vi.mock("../workload/preparation", async (importOriginal) => { return { ...original, prepareSandboxWorkloadSource }; }); -vi.mock("../../core/version", () => ({ getVersion: () => "v0.0.0" })); +vi.mock("../../core/version", () => ({ + getBuildIdentity: () => ({ nemoclawVersion: "0.0.0", sourceRevision: INSTALLED_REVISION }), + getVersion: () => "v0.0.0", +})); import { createManagedHermesStateVolumeOnboardLifecycle, @@ -71,7 +77,7 @@ function createFreshOnboardingRuntime( agentName: "openclaw", legacyDockerfilePath: "agents/openclaw/Dockerfile", customDockerfilePath: null, - rootDir: "/tmp/nemoclaw", + rootDir: releaseRoot, model: "model", provider: "provider", preferredInferenceApi: null, @@ -118,6 +124,10 @@ async function expectUnsupportedHermesPortableSources( } describe("managed workload onboard orchestration", () => { + afterAll(() => { + fs.rmSync(releaseRoot, { force: true, recursive: true }); + }); + it("activates stock managed images only for shipped agents outside Portable", () => { expect( shouldActivateStockManagedRuntime({ @@ -237,7 +247,7 @@ describe("managed workload onboard orchestration", () => { }, ); - lifecycle.materializeSandboxCreatePlan({} as never, (input) => { + lifecycle!.materializeSandboxCreatePlan({} as never, (input) => { expect(input.managedStateMount).toMatchObject({ target: "/sandbox/.hermes" }); return {} as never; }); @@ -249,10 +259,13 @@ describe("managed workload onboard orchestration", () => { it("retains the live qualification catalog revision during fresh onboarding (#9385)", async () => { const catalogRevision = "a".repeat(40); - const { prepared, runtime } = createFreshOnboardingRuntime({ - GITHUB_ACTIONS: "true", - E2E_MANAGED_IMAGE_REVISION: catalogRevision, - }); + const { prepared, runtime } = createFreshOnboardingRuntime( + { + GITHUB_ACTIONS: "true", + E2E_MANAGED_IMAGE_REVISION: catalogRevision, + }, + { stockManagedRuntime: true }, + ); await expect(runtime.ensurePreparedWorkload()).resolves.toBe(prepared); expect(prepareSandboxWorkloadSource).toHaveBeenCalledExactlyOnceWith( @@ -260,6 +273,17 @@ describe("managed workload onboard orchestration", () => { ); }); + it("does not apply the stock cohort revision outside stock onboarding", async () => { + const { prepared, runtime } = createFreshOnboardingRuntime({ + GITHUB_ACTIONS: "true", + E2E_MANAGED_IMAGE_REVISION: "a".repeat(40), + }); + + await expect(runtime.ensurePreparedWorkload()).resolves.toBe(prepared); + expect(prepareSandboxWorkloadSource).toHaveBeenCalledOnce(); + expect(prepareSandboxWorkloadSource.mock.calls[0]?.[0]).not.toHaveProperty("catalogRevision"); + }); + it("binds fresh onboarding to the exact PR catalog (#9464)", async () => { const catalogRevision = "b".repeat(40); const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-live-e2e-catalog-")); @@ -295,14 +319,35 @@ describe("managed workload onboard orchestration", () => { expect(prepareSandboxWorkloadSource.mock.calls[0]?.[0]).not.toHaveProperty("catalogRevision"); }); + it("retains an exact installed revision outside GitHub Actions", async () => { + const { prepared, runtime } = createFreshOnboardingRuntime({ + NEMOCLAW_INSTALL_REF: INSTALLED_REVISION, + }); + + await expect(runtime.ensurePreparedWorkload()).resolves.toBe(prepared); + expect(prepareSandboxWorkloadSource).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ catalogRevision: INSTALLED_REVISION }), + ); + }); + it("resolves final-image patch metadata after managed build-context staging", async () => { const resolutionMetadata = { key: "published-dcode-base" }; + const trustedDockerfile = path.join( + process.cwd(), + "agents", + "langchain-deepagents-code", + "Dockerfile", + ); let staged = false; const resolvePatchInput = vi.fn(() => { expect(staged).toBe(true); - return { preResolvedBaseImageMetadata: resolutionMetadata } as never; + return { + fromDockerfile: trustedDockerfile, + preResolvedBaseImageMetadata: resolutionMetadata, + } as never; }); const resolveSandboxBuildPatch = vi.fn(async (input: Record) => { + expect(input.fromDockerfile).toBeNull(); expect(input.preResolvedBaseImageMetadata).toBe(resolutionMetadata); expect(input.stagedDockerfile).toBe("/tmp/nemoclaw-staged-context/Dockerfile"); return { buildId: "dcode-build", dashboardRemoteBindPrepared: false }; @@ -348,8 +393,9 @@ describe("managed workload onboard orchestration", () => { agent: { name: "langchain-deepagents-code", displayName: "LangChain Deep Agents Code", + dockerfilePath: trustedDockerfile, }, - fromDockerfile: null, + fromDockerfile: trustedDockerfile, createAgentSandbox: () => { staged = true; return { diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.ts b/src/lib/onboard/managed-workload/onboard-orchestration.ts index 07f71433d58..dc090945816 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.ts @@ -32,6 +32,7 @@ import type { MessagingTokenDef } from "../messaging-prep"; import { resolveSandboxBuildContext, resolveSandboxBuildPatch } from "../prepared-dcode-rebuild"; import { CURRENT_RUNTIME_PROVIDER_BUNDLES, + normalizeRuntimeProviderIdentity, type RuntimeProviderBundle, resolveRuntimeProviderBundle, } from "../runtime-provider/access"; @@ -54,6 +55,7 @@ import { import { getSandboxReadyTimeoutSecs } from "../sandbox-gpu-create"; import type { SandboxGpuConfig } from "../sandbox-gpu-mode"; import { + installedManagedImageCatalogRevision, liveE2eManagedImageCatalog, liveE2eManagedImageRevision, type PreparedSandboxWorkloadSource, @@ -66,6 +68,7 @@ import { import { resolveSandboxWorkloadRuntimeCapabilities } from "../workload/runtime"; import { prepareManagedHermesStateVolume, + removeManagedHermesStateVolume, type ManagedHermesStateVolumeContext, type ManagedHermesStateVolumeDeps, } from "./hermes-state-volume"; @@ -80,6 +83,8 @@ type BootstrapProvider = RuntimeProviderBundle & { readonly bootstrap: RuntimeProviderManagedImageBootstrapSurface; }; +export { normalizeRuntimeProviderIdentity, removeManagedHermesStateVolume }; + export type ManagedHermesStateVolumeOnboardLifecycle = { materializeSandboxCreatePlan( input: MaterializeSandboxCreatePlanInput, @@ -240,11 +245,18 @@ export function createManagedWorkloadOnboardRuntime( let preparedProfile: BuiltManagedStartupOnboardProfile | null = null; const ensurePreparedWorkload = async (): Promise => { - const catalogRevision = liveE2eManagedImageRevision(input.startupProfile.environment); + const liveCatalogRevision = input.stockManagedRuntime + ? liveE2eManagedImageRevision(input.startupProfile.environment) + : null; const liveCatalog = liveE2eManagedImageCatalog(input.startupProfile.environment); - if (catalogRevision && liveCatalog) { + if (liveCatalogRevision && liveCatalog) { throw new Error("live E2E managed-image revision and catalog authority conflict"); } + const catalogRevision = + liveCatalogRevision ?? + (liveCatalog || input.tempManagedRuntimeCatalog || input.managedWorkloadRebuild + ? null + : installedManagedImageCatalogRevision(input.startupProfile.environment, input.rootDir)); preparedWorkloadPromise ??= input.managedWorkloadRebuild ? Promise.resolve( prepareSandboxWorkloadSourceFromRebuildHandoff( @@ -480,11 +492,15 @@ export async function prepareOnboardSandboxWorkloadLaunch( } else { const buildContext = requireLegacyBuildContext(legacyBuildContext); input.dependencies.prepareSandboxBuildPatchConfig({ configuredMessagingChannels }); + const patchInput = input.legacy.resolvePatchInput(); const patch = await (input.dependencies.resolveSandboxBuildPatch ?? resolveSandboxBuildPatch)({ // Build-context staging resolves managed-agent base-image provenance. // Read the patch input only after that boundary so the final image gets // the exact metadata produced by the same staging operation. - ...input.legacy.resolvePatchInput(), + ...patchInput, + // An explicit path to the checked-in agent Dockerfile is staged through + // the trusted agent builder. Preserve that classification at patch time. + fromDockerfile: buildContext.origin === "generated" ? null : patchInput.fromDockerfile, selectedGpuRoute: initialGpuRoute, stagedDockerfile: buildContext.stagedDockerfile, }); diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts index 015fda9710d..875e5303072 100644 --- a/src/lib/onboard/messaging-prep.test.ts +++ b/src/lib/onboard/messaging-prep.test.ts @@ -83,6 +83,31 @@ describe("prepareCreateSandboxMessaging", () => { ); }); + it("reattaches an exact durable provider when rebuild resumes without channel prompts", () => { + const providerMatchesGatewayCredential = vi.fn( + (name: string, type: string, credentialKey: string) => + name === "demo-discord-bridge" && + type === "nemoclaw-mcp-v1" && + credentialKey === "DISCORD_BOT_TOKEN", + ); + + const result = prepareCreateSandboxMessaging( + createInput({ + enabledChannels: null, + requireExactProviderBinding: true, + providerMatchesGatewayCredential, + }), + ); + + expect(result.reusableMessagingProviders).toContain("demo-discord-bridge"); + expect(result.reusableMessagingChannels).toContain("discord"); + expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( + "demo-discord-bridge", + "nemoclaw-mcp-v1", + "DISCORD_BOT_TOKEN", + ); + }); + it("reuses an existing gateway bridge provider when the bridge secret is not resolvable", () => { // Deferred rebuild in a fresh process: the pasted secret is env-only and // gone, so no bridge token def exists — but the gateway still durably diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts index 2e52abe84cd..cdf40a51b00 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -192,7 +192,7 @@ export function prepareCreateSandboxMessaging( : []; const reusableMessagingChannels: string[] = []; - if (input.enabledChannels != null) { + if (input.enabledChannels != null || input.requireExactProviderBinding === true) { for (const { name, envKey, @@ -203,7 +203,11 @@ export function prepareCreateSandboxMessaging( const channel = input.getMessagingChannelForEnvKey(envKey); if (!channel) continue; const channelDisabled = disabledChannelNames.has(channel); - if (!input.enabledChannels.includes(channel) && !(channelDisabled && retainWhileDisabled)) { + if ( + input.enabledChannels != null && + !input.enabledChannels.includes(channel) && + !(channelDisabled && retainWhileDisabled) + ) { continue; } if (channelDisabled && !retainWhileDisabled) continue; @@ -231,10 +235,10 @@ export function prepareCreateSandboxMessaging( // The name carries the channel but not the agent, and onboard can recreate a // sandbox name under a different agent, so match the gateway binding against // the selected profile rather than accepting any provider with that name. - if (input.enabledChannels != null) { + if (input.enabledChannels != null || input.requireExactProviderBinding === true) { for (const profile of bridgeProfiles) { const channel = profile.channelId; - if (!input.enabledChannels.includes(channel)) continue; + if (input.enabledChannels != null && !input.enabledChannels.includes(channel)) continue; if (disabledChannelNames.has(channel)) continue; for (const name of bridgeProviderNamesForChannel(input.sandboxName, channel, [profile])) { if (messagingTokenDefs.some((def) => def.name === name && def.token)) continue; diff --git a/src/lib/onboard/sandbox-create-plan.test.ts b/src/lib/onboard/sandbox-create-plan.test.ts index 49e3e07d72a..09a90f5333b 100644 --- a/src/lib/onboard/sandbox-create-plan.test.ts +++ b/src/lib/onboard/sandbox-create-plan.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { afterEach, describe, expect, it, vi } from "vitest"; +import { testTimeoutOptions } from "../../../test/helpers/timeouts"; import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../messaging/provider-profile"; import type { MessagingTokenDef } from "./messaging-prep"; import { materializeHermesPortableCreatePlan } from "./sandbox-create-plan-materialization"; @@ -315,34 +316,38 @@ describe("resolveSandboxCreateIntent", () => { expect(JSON.stringify(first)).not.toContain("/tmp/"); }); - it("omits Discord create-time effects when an unselected credential is available", () => { - const { intent, messagingTokenDefs } = resolveDiscordCreateIntent({ - selected: false, - reusable: true, - }); - const upsertMessagingProviders = vi.fn((tokenDefs: MessagingTokenDef[]) => - tokenDefs.map(({ name }) => name), - ); - - const plan = materializeDiscordCreatePlan( - { intent, messagingTokenDefs }, - { upsertMessagingProviders }, - ); - - expect(intent.reusableMessagingProviders).toEqual([]); - expect(plan.activeMessagingChannels).toEqual([]); - expect(plan.initialSandboxPolicy.appliedPresets).not.toContain("discord"); - expect(plan.initialSandboxPolicy.credentialBindingProviders ?? []).not.toContain( - discordProviderName, - ); - expect(plan.messagingProviders).not.toContain(discordProviderName); - expect(plan.createArgs).not.toContain(discordProviderName); - expect(upsertMessagingProviders).toHaveBeenCalledWith([], { - replaceExisting: true, - allowedSandboxes: ["sandbox"], - }); - plan.initialSandboxPolicy.cleanup?.(); - }); + it( + "omits Discord create-time effects when an unselected credential is available", + testTimeoutOptions(15_000), + () => { + const { intent, messagingTokenDefs } = resolveDiscordCreateIntent({ + selected: false, + reusable: true, + }); + const upsertMessagingProviders = vi.fn((tokenDefs: MessagingTokenDef[]) => + tokenDefs.map(({ name }) => name), + ); + + const plan = materializeDiscordCreatePlan( + { intent, messagingTokenDefs }, + { upsertMessagingProviders }, + ); + + expect(intent.reusableMessagingProviders).toEqual([]); + expect(plan.activeMessagingChannels).toEqual([]); + expect(plan.initialSandboxPolicy.appliedPresets).not.toContain("discord"); + expect(plan.initialSandboxPolicy.credentialBindingProviders ?? []).not.toContain( + discordProviderName, + ); + expect(plan.messagingProviders).not.toContain(discordProviderName); + expect(plan.createArgs).not.toContain(discordProviderName); + expect(upsertMessagingProviders).toHaveBeenCalledWith([], { + replaceExisting: true, + allowedSandboxes: ["sandbox"], + }); + plan.initialSandboxPolicy.cleanup?.(); + }, + ); it("attaches the selected Discord provider to its create-time policy", () => { const { intent, messagingTokenDefs } = resolveDiscordCreateIntent({ diff --git a/src/lib/onboard/sandbox-create/orchestration.ts b/src/lib/onboard/sandbox-create/orchestration.ts index 9d630e6daf7..5ac5ac3413d 100644 --- a/src/lib/onboard/sandbox-create/orchestration.ts +++ b/src/lib/onboard/sandbox-create/orchestration.ts @@ -19,6 +19,10 @@ import type { HermesAuthMethod } from "../hermes-auth"; import * as policyAuthorityPreflight from "../policy-authority/preflight"; import type { PreparedSandboxBuildContext } from "../build-context-stage"; import type { DcodeSelectionDriftReader } from "../dcode-selection-drift"; +import type { + ManagedHermesStateVolumeCleanupResult, + ManagedHermesStateVolumeContext, +} from "../managed-workload/hermes-state-volume"; import type { OwnedSandboxRecreateRuntime } from "../onboard-recreate-journal"; import type { SandboxGpuConfig } from "../sandbox-gpu-mode"; import type { PortableOnboardRuntimeContext } from "../session-bootstrap"; @@ -91,6 +95,66 @@ type SandboxRecreateReasonInput = { existingSandboxState: string; }; +type RecreatedSourceHermesStateVolumeCleanupInput = { + readonly sandboxName: string; + readonly sourceEntry: SandboxEntry | null; + readonly targetKeepsManagedHermesStateVolume: boolean; +}; + +type RecreatedSourceHermesStateVolumeCleanupDeps = { + readonly normalizeRuntimeProviderIdentity: (driverName: string | null | undefined) => string; + readonly removeManagedHermesStateVolume: ( + context: ManagedHermesStateVolumeContext, + ) => ManagedHermesStateVolumeCleanupResult; + readonly note: (message: string) => void; + readonly warn: (message: string) => void; + readonly redact: (message: string) => string; +}; + +type RecreatedSourceHermesStateVolumeFinalizationInput = + RecreatedSourceHermesStateVolumeCleanupInput & { + readonly sourceConfirmedAbsent: boolean; + }; + +type RecreatedSourceHermesStateVolumeFinalizationDeps = + RecreatedSourceHermesStateVolumeCleanupDeps & { + readonly removeSourceRegistryEntry: (entry: SandboxEntry, sandboxName: string) => void; + }; + +export function cleanupRecreatedSourceHermesStateVolume( + input: RecreatedSourceHermesStateVolumeCleanupInput, + deps: RecreatedSourceHermesStateVolumeCleanupDeps, +): void { + if (!input.sourceEntry || input.targetKeepsManagedHermesStateVolume) return; + + const cleanup = deps.removeManagedHermesStateVolume({ + agentName: input.sourceEntry.agent, + runtimeProviderId: deps.normalizeRuntimeProviderIdentity(input.sourceEntry.openshellDriver), + sandboxName: input.sandboxName, + workloadKind: input.sourceEntry.workload?.kind ?? "", + }); + if (cleanup.status === "failed") { + throw new Error( + `OpenShell confirmed that sandbox '${input.sandboxName}' is absent, but Docker could not remove its managed Hermes state volume '${cleanup.volumeName}': ${deps.redact(cleanup.detail)}. NemoClaw preserved the sandbox registry entry so a subsequent recreation can retry the volume removal.`, + ); + } + if (cleanup.status === "not-owned") { + deps.warn(` Left Docker volume '${cleanup.volumeName}' untouched because ${cleanup.detail}.`); + } else if (cleanup.status === "removed") { + deps.note(` Removed managed Hermes state volume for '${input.sandboxName}'.`); + } +} + +export function finalizeRecreatedSourceHermesStateVolume( + input: RecreatedSourceHermesStateVolumeFinalizationInput, + deps: RecreatedSourceHermesStateVolumeFinalizationDeps, +): void { + if (!input.sourceConfirmedAbsent || !input.sourceEntry) return; + + cleanupRecreatedSourceHermesStateVolume(input, deps); + deps.removeSourceRegistryEntry(input.sourceEntry, input.sandboxName); +} + export function readManagedDcodeCreateSelectionDrift( input: { sandboxName: string; @@ -877,6 +941,27 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche sandboxName, workloadKind: workload.source.kind, }); + const finalizeRecreatedSourceHermesVolume = ( + sourceConfirmedAbsent: boolean, + sourceEntry: SandboxEntry | null, + targetKeepsManagedHermesStateVolume: boolean, + ) => + finalizeRecreatedSourceHermesStateVolume( + { + sandboxName, + sourceConfirmedAbsent, + sourceEntry, + targetKeepsManagedHermesStateVolume, + }, + { + normalizeRuntimeProviderIdentity: managedWorkloadOnboard.normalizeRuntimeProviderIdentity, + removeManagedHermesStateVolume: managedWorkloadOnboard.removeManagedHermesStateVolume, + removeSourceRegistryEntry: sandboxLifecycle.removeSandboxUnlessSessionReservation, + note, + warn: (message) => console.warn(message), + redact, + }, + ); await validatePortableManagedWorkloadSelection({ portableLifecycle: agentCreateInput.portableLifecycle, selectionNeedsValidation: tempManagedRuntime || managedWorkloadRebuild !== null, @@ -1371,7 +1456,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche ); } recreateRuntime.confirmDeleted(); - sandboxLifecycle.removeSandboxUnlessSessionReservation(previousEntry, sandboxName); + finalizeRecreatedSourceHermesVolume(true, previousEntry, hermesStateVolumeLifecycle !== null); await hermesApiPortReservationScope.rebindAfterOwnedForwardDelete( hermesApiPortReservationInput, ); @@ -1382,6 +1467,11 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche } preparedSandboxWorkload = await ensurePreparedSandboxWorkload(); hermesStateVolumeLifecycle = prepareHermesStateVolumeLifecycle(preparedSandboxWorkload); + finalizeRecreatedSourceHermesVolume( + !liveExists, + existingEntry, + hermesStateVolumeLifecycle !== null, + ); } revalidatePolicyAuthority(false, `creating sandbox '${sandboxName}'`); sandboxCreatePlanMaterialization.applyOrdinaryExtraProviderReconciliation( @@ -1533,10 +1623,12 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche }, dependencies: { materializeSandboxCreatePlan: (input) => - hermesStateVolumeLifecycle.materializeSandboxCreatePlan( - input, - sandboxCreatePlanMaterialization.materializeSandboxCreatePlan, - ), + hermesStateVolumeLifecycle + ? hermesStateVolumeLifecycle.materializeSandboxCreatePlan( + input, + sandboxCreatePlanMaterialization.materializeSandboxCreatePlan, + ) + : sandboxCreatePlanMaterialization.materializeSandboxCreatePlan(input), prepareSandboxBuildPatchConfig: sandboxBuildPatchConfig.prepareSandboxBuildPatchConfig, }, @@ -2051,7 +2143,7 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche cleanupInitialCreateSource(); } } - hermesStateVolumeLifecycle.commit(); + hermesStateVolumeLifecycle?.commit(); if ("complete" in recreateRuntime) recreateRuntime.complete(); if (agentCreateInput.hermesPortableLifecycle) return sandboxName; return completeOrdinaryOnboardSandboxCreation( diff --git a/src/lib/onboard/sandbox-workload-preparation.test.ts b/src/lib/onboard/sandbox-workload-preparation.test.ts index 135097d9ac9..b7932724ea1 100644 --- a/src/lib/onboard/sandbox-workload-preparation.test.ts +++ b/src/lib/onboard/sandbox-workload-preparation.test.ts @@ -293,7 +293,7 @@ describe("sandbox workload preparation", () => { catalogPath, expectedCatalogRevision: "b".repeat(40), }), - ).rejects.toThrow("does not match the live E2E candidate revision"); + ).rejects.toThrow("does not match the trusted catalog revision"); } finally { fs.rmSync(fixtureRoot, { force: true, recursive: true }); } @@ -321,6 +321,28 @@ describe("sandbox workload preparation", () => { } }); + it("rejects a registry catalog that does not match its trusted revision", async () => { + await expect( + prepareSandboxWorkloadSource( + { + ...input("hermes"), + version: "0.1.0", + catalogRevision: "b".repeat(40), + }, + { resolveCatalog: async () => CATALOG }, + ), + ).rejects.toThrow("does not match the trusted catalog revision"); + }); + + it("rejects a cross-release catalog without an exact trusted revision", async () => { + await expect( + prepareSandboxWorkloadSource( + { ...input("langchain-deepagents-code"), version: "0.1.0" }, + { resolveCatalog: async () => CATALOG }, + ), + ).rejects.toThrow("belongs to 'v0.0.97', not 'v0.1.0'"); + }); + it("loads an exact local all-agent catalog without using the registry resolver (#7744)", async () => { const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-catalog-")); const catalogPath = path.join(fixtureRoot, "catalog.json"); diff --git a/src/lib/onboard/workload/preparation.ts b/src/lib/onboard/workload/preparation.ts index 0f87233f7da..e0644ae2107 100644 --- a/src/lib/onboard/workload/preparation.ts +++ b/src/lib/onboard/workload/preparation.ts @@ -286,7 +286,7 @@ function requireCompleteManagedImageCatalog( } if (expectedRevision !== null && cohortRevision !== expectedRevision) { throw new SandboxWorkloadPreparationError( - "managed image catalog source revision does not match the live E2E candidate revision", + "managed image catalog source revision does not match the trusted catalog revision", ); } return { release: cohortRelease!, revision: cohortRevision! }; @@ -373,6 +373,22 @@ export async function prepareSandboxWorkloadSource( ); } + const trustedCatalogRevision = input.expectedCatalogRevision ?? input.catalogRevision ?? null; + if ( + input.expectedCatalogRevision && + input.catalogRevision && + input.expectedCatalogRevision !== input.catalogRevision + ) { + throw new SandboxWorkloadPreparationError( + "managed image catalog has conflicting trusted revision authorities", + ); + } + if (trustedCatalogRevision !== null && !/^[0-9a-f]{40}$/u.test(trustedCatalogRevision)) { + throw new SandboxWorkloadPreparationError( + "managed image catalog trusted revision must be a lowercase 40-character SHA", + ); + } + let release: string; try { release = normalizeManagedImageRelease(input.version); @@ -420,9 +436,6 @@ export async function prepareSandboxWorkloadSource( acceptedCandidateContract, ); } else { - const trustedCatalogRevision = input.catalogPath - ? (input.expectedCatalogRevision ?? null) - : (input.catalogRevision ?? null); const catalogIdentity = requireCompleteManagedImageCatalog( catalog, release, diff --git a/test/agents/openclaw/runtime/nemoclaw-start-gateway-ws-host.test.ts b/test/agents/openclaw/runtime/nemoclaw-start-gateway-ws-host.test.ts index 92425964989..5db277fb223 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start-gateway-ws-host.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start-gateway-ws-host.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from "vitest"; const requireForTest = createRequire(import.meta.url); const YAML = requireForTest("yaml"); -const START_SCRIPT = path.join(import.meta.dirname, "..", "../../..", "scripts", "nemoclaw-start.sh"); +const START_SCRIPT = path.join(import.meta.dirname, "../../../..", "scripts", "nemoclaw-start.sh"); const startScriptSource = fs.readFileSync(START_SCRIPT, "utf-8"); @@ -558,7 +558,7 @@ describe("gateway websocket url host derivation", () => { describe("gateway dial-back base policy", () => { function loadYaml(relativePath: string): Record { return YAML.parse( - fs.readFileSync(path.join(import.meta.dirname, "..", "../../..", relativePath), "utf-8"), + fs.readFileSync(path.join(import.meta.dirname, "../../../..", relativePath), "utf-8"), ) as Record; } diff --git a/test/credentials/openshell-credential-generation-window.test.ts b/test/credentials/openshell-credential-generation-window.test.ts index be9ff51a642..10e0b4d7f99 100644 --- a/test/credentials/openshell-credential-generation-window.test.ts +++ b/test/credentials/openshell-credential-generation-window.test.ts @@ -52,7 +52,7 @@ describe("OpenShell exact-main credential generation-window proof", () => { expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.fallbackAfterEviction)); expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.deniedAfterKeyRemoval)); expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.deniedAfterDetach)); - expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.fallbackAfterRestart)); + expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.deniedAfterReadd)); expect(script).toContain(JSON.stringify(CREDENTIAL_WINDOW_STEPS.stop)); expect(script).not.toContain(MCP_BRIDGE_TEST_CREDENTIALS.generationWindow); }); @@ -104,7 +104,9 @@ describe("OpenShell exact-main credential generation-window proof", () => { expect(liveTarget).toContain('["nemoclaw-start", "node", "-e"'); expect(liveTarget).toContain("CREDENTIAL_WINDOW_STEPS.deniedAfterExpiry"); expect(liveTarget).toContain("CREDENTIAL_WINDOW_STEPS.deniedAfterKeyRemoval"); - expect(liveTarget).toContain('["sandbox", "provider", "detach"'); + expect(liveTarget).toContain('[SANDBOX_NAME, "mcp", "remove", SERVER_NAME]'); + expect(liveTarget).toMatch(/\[\s*SANDBOX_NAME,\s*"mcp",\s*"add",\s*SERVER_NAME,/u); + expect(liveTarget).not.toContain('["sandbox", "provider", "detach"'); expect(liveTarget).toContain('[SANDBOX_NAME, "mcp", "restart", SERVER_NAME]'); expect(liveTarget).toContain('[SANDBOX_NAME, "rebuild", "--yes"]'); expect(liveTarget).toContain('!request.auth.includes("openshell:resolve:env")'); diff --git a/test/e2e/README.md b/test/e2e/README.md index e45058c1bdc..0aede4e6770 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -90,6 +90,14 @@ After the checks pass, the action restores root `dist/` and `nemoclaw/dist/share If the version command fails, the action stops before the live test runs. This boundary keeps candidate source separate from the trusted workflow implementation. +The `base-image-publication` job selects the nearest fully successful publication on the PR base +first-parent history. It downloads the complete cohort contract and Deep Agents Code base contract +by immutable artifact ID. It binds each artifact to the selected workflow run, attempt, revision, +artifact ID, and artifact digest. The cohort validator requires OpenClaw, Hermes, and LangChain Deep +Agents Code on `linux/amd64` and `linux/arm64` before it emits `managed_image_revision`. +`generate-matrix` and every stock-onboarding job depend on this job, so a missing, failed, +incomplete, or mixed publication starts no onboarding consumer. + For a same-repository PR that changes a managed-image workflow path, the trusted planner also requires one successful `Images / Build, Test, and Publish Managed Images` run for the candidate commit. Before candidate checkout, the planner downloads the three nonexpired contract artifacts by @@ -99,8 +107,11 @@ missing, incomplete, or mixed all-agent publication before E2E jobs start. The planner adds the exact all-agent catalog to `dist/` after the candidate CLI build completes. Each live E2E consumer verifies that the catalog source revision matches `checkout_sha`. A PR that -does not change a managed-image workflow path keeps the released catalog behavior. The GitHub token -is available only to the trusted planner job and is not included in the candidate CLI artifact. +does not change a managed-image workflow path receives the selected base publication through +`E2E_MANAGED_IMAGE_REVISION` and the complete cohort receipt. Every stock-onboarding test asserts +its durable `managed-image` receipt against either the exact candidate catalog or the selected base +cohort before later probes. The GitHub token is available only to the trusted planner job and is not +included in the candidate CLI artifact. The same-repository `Images / Build, Test, and Publish Managed Images` PR workflow also runs the OpenClaw managed-image MCP discovery and lifecycle scope in two independent matrix jobs. Each job @@ -127,7 +138,7 @@ This baseline measures only the replaced build step. Artifact upload, download, validation, and the dependency on `generate-matrix` add runtime and can affect the workflow critical path. Do not use the build-step median to claim savings in runner time or workflow elapsed time. -A manual PR E2E run tests candidate code but executes `.github/workflows/e2e.yaml` from `main`. +A manual PR E2E run tests candidate code but executes `.github/workflows/e2e.yaml` from trusted `main`. The PR run cannot measure this workflow change before merge. After merge, use a passing `main` run and complete these steps: @@ -634,8 +645,7 @@ to use `ubuntu-latest`. The trusted `generate-matrix` job builds one runner map before checking out test code, and it consumes the variable only when the workflow repository is `NVIDIA/NemoClaw`, the ref is `refs/heads/main`, and no alternate checkout SHA is requested. Manual PR E2E dispatches therefore remain on -standard runners even though they use the trusted workflow definition from -`main`. +standard runners even though they use the trusted workflow definition from `main`. Manual PR E2E dispatches and direct push or manual `main` runs use a bounded swap fallback for eligible hosted Hermes image-building lanes. The @@ -1335,6 +1345,12 @@ It does not run GitHub's synthetic merge commit. Before candidate execution, the workflow uploads a `nemoclaw-e2e-dispatch-v2` receipt for the trusted manual run. The full-main `Release qualification` aggregate does not use this receipt. +The `base-image-publication` job selects the nearest fully successful base and managed-image publication on the PR base first-parent history. +It binds the selected run ID, attempt, revision, cohort contract artifact ID, and artifact digest before it emits `managed_image_revision`. +The job validates the complete three-agent, two-architecture cohort artifact and the immutable Deep Agents Code base artifact from that workflow attempt. +`generate-matrix` and every stock-onboarding job depend on this publication job, so incomplete publication creates no onboarding fanout. +Direct `main` runs use the same publication workflow and artifact contract. + PR Review Advisor maps changes to either of these shared journaled-recreation handlers to recommended E2E coverage: - `src/lib/onboard/machine/handlers/sandbox-resume.ts`. @@ -1475,12 +1491,11 @@ No PR E2E controller dispatches the risk plan. The `full-e2e` target enforces a separate hard acceptance contract for the first fresh onboarding path in that job. It measures from the onboard root span (a conservative anchor before wizard step `[1/8]`) through the first non-empty -agent response and reads the registered workload receipt. A `legacy-dockerfile` -receipt requires the local BuildKit prebuild without a gateway-builder fallback. -A `managed-image` receipt instead requires an exact digest that matches the -registered sandbox image tag, a non-empty publication cohort, and an exact -40-character source revision, and it forbids a local BuildKit prebuild. Both -paths enforce the calibrated root and phase limits in the budget file and limit +agent response and reads the registered workload receipt. The receipt must be +`managed-image`, use an exact digest that matches the registered sandbox image +tag, identify the selected publication cohort and exact source revision, and +forbid a local BuildKit prebuild. The path enforces the calibrated root and phase +limits in the budget file and limits the longest onboard output gap to 60 seconds. A violation fails `full-e2e`, and the target writes its evidence to `onboard-progress-budget.json`. The artifact records the first-turn command wall clock and OpenClaw's internal @@ -1494,10 +1509,10 @@ PR regression. The same overage remains blocking when accompanied by a root-start or phase-budget failure. The checked-in `nemoclaw.onboard.phase.sandbox` budget remains 208,000 ms. -A sandbox-phase overage qualifies for anomaly classification only when it is the sole performance overage and the run uses the published-base build mode without the authoritative local base-build allowance. +A sandbox-phase overage qualifies for anomaly classification only when it is the sole performance overage and the run uses a managed image. For a qualifying overage of at most 5,000 ms, `full-e2e` records a `sandbox-phase-tail` anomaly instead of a blocking performance violation. An overage greater than 5,000 ms remains blocking. -A run that applies the authoritative local base-build allowance or has another performance violation also remains blocking. +A local BuildKit prebuild fails the workload-evidence contract and cannot qualify for anomaly classification. Every other performance contract remains blocking, as do the existing first-turn command exit, BuildKit, gateway-builder no-fallback, output-silence, sentinel, E2E job outcome, and cleanup contracts. For `sandbox-phase-tail`, the trusted push scorecard uses the latest five eligible samples from the same agent, setup mode, platform, base-build mode, and workload kind. @@ -1527,13 +1542,6 @@ The canonical E2E uploader retains each push summary for 14 days. The sandbox-phase recurrence rule does not recalibrate the checked-in budget. Recalibration remains deferred until five successful samples from the same commit are available. -When changed base-image inputs require the authoritative local OpenClaw base -build, the target applies the separately calibrated 90-second allowance only to -the root-start and sandbox-phase limits. The installer must emit the exact local -base-build reason before the allowance applies. Published-image runs retain the -normal limits, and output silence, first-turn, and all other phase requirements -remain unchanged. - The two Hermes rebuild jobs and both reusable-workflow Hermes image exporters add a bounded 32 GiB swap file on their ephemeral hosted runners before the memory-heavy image build. The rebuild fixture verifies that floor and diff --git a/test/e2e/fixtures/availability-env.ts b/test/e2e/fixtures/availability-env.ts index 29736629163..2d11753fcdc 100644 --- a/test/e2e/fixtures/availability-env.ts +++ b/test/e2e/fixtures/availability-env.ts @@ -10,6 +10,8 @@ const AVAILABILITY_PROBE_EXTRA_ENV_KEYS = [ "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "DOCKER_API_VERSION", + "E2E_MANAGED_IMAGE_REVISION", + "E2E_MANAGED_IMAGE_COHORT_RECEIPT", "GITHUB_WORKSPACE", "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR", @@ -24,10 +26,10 @@ const AVAILABILITY_PROBE_EXTRA_ENV_KEYS = [ export function buildAvailabilityProbeEnv( base: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - // Availability probes run outside live target phases, but they need - // the same child-env and PATH policy. Add Docker discovery knobs and the - // workflow-owned local-model pull budget and exact PR catalog authority on - // top of the shared boundary. + // Availability probes run outside live target phases but need the shared + // child environment and PATH policy. Add Docker discovery settings, the + // workflow-owned local-model pull budget, and the selected managed-image + // cohort revision and receipt to that boundary. return buildChildEnv(base, { additionalAllowedEnv: AVAILABILITY_PROBE_EXTRA_ENV_KEYS, fixtureOverlay: {}, diff --git a/test/e2e/fixtures/clients/host.ts b/test/e2e/fixtures/clients/host.ts index 5d1bcbb123b..4991582e2c3 100644 --- a/test/e2e/fixtures/clients/host.ts +++ b/test/e2e/fixtures/clients/host.ts @@ -4,6 +4,10 @@ import { isAbsolute } from "node:path"; import { buildAvailabilityProbeEnv } from "../availability-env.ts"; +import { + assertStockManagedImageReceipt, + shouldAssertStockManagedImageReceipt, +} from "../managed-image-receipt.ts"; import type { ShellProbeResult, ShellProbeRunOptions } from "../shell-probe.ts"; import { trustedShellCommand } from "../shell-probe.ts"; import { @@ -48,7 +52,7 @@ export class HostCliClient { return this.openshellPath; } - command( + async command( command: string, args: string[] = [], options: ShellProbeRunOptions = {}, @@ -57,7 +61,7 @@ export class HostCliClient { if (this.cwd && !merged.cwd) { merged.cwd = this.cwd; } - return this.runner.run( + const result = await this.runner.run( trustedShellCommand({ command, args, @@ -65,6 +69,22 @@ export class HostCliClient { }), merged, ); + const environment = merged.env ?? {}; + if ( + result.exitCode === 0 && + shouldAssertStockManagedImageReceipt(command, args, environment) + ) { + const sandboxName = environment.NEMOCLAW_SANDBOX_NAME?.trim(); + if (!sandboxName) { + throw new Error("stock managed-image receipt assertion requires a sandbox name"); + } + assertStockManagedImageReceipt({ + environment, + expectedAgent: environment.NEMOCLAW_AGENT?.trim(), + sandboxName, + }); + } + return result; } async isCommandAvailable(command: string, options: ShellProbeRunOptions = {}): Promise { diff --git a/test/e2e/fixtures/managed-image-receipt.ts b/test/e2e/fixtures/managed-image-receipt.ts new file mode 100644 index 00000000000..41a43aebd1f --- /dev/null +++ b/test/e2e/fixtures/managed-image-receipt.ts @@ -0,0 +1,295 @@ +// 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 { DEFAULT_GATEWAY_PORT } from "../../../src/lib/core/ports.ts"; +import { + isShippedManagedImageAgent, + MANAGED_IMAGE_PLATFORMS, + MANAGED_IMAGE_REPOSITORIES, + parseManagedImageContractV1, + SHIPPED_MANAGED_IMAGE_AGENTS, + type ManagedImageContractV1, + type ManagedImagePlatform, + type ShippedManagedImageAgent, +} from "../../../src/lib/onboard/managed-image/contract.ts"; +import { readManagedWorkloadAuthority } from "../../../src/lib/onboard/workload/authority.ts"; +import { liveE2eManagedImageCatalog } from "../../../src/lib/onboard/workload/preparation.ts"; +import { readConfigFile } from "../../../src/lib/state/config-io.ts"; +import { parseSandboxRegistryEntries } from "../../../src/lib/state/registry-normalization.ts"; +import { cloneSandboxWorkloadReceipt } from "../../../src/lib/state/registry/workload.ts"; +import { nemoclawStateRoot } from "../../../src/lib/state/state-root.ts"; + +const REVISION_PATTERN = /^[0-9a-f]{40}$/u; + +function readCandidateCatalog( + environment: NodeJS.ProcessEnv, +): ReadonlyMap { + const selected = liveE2eManagedImageCatalog(environment); + if (!selected) { + throw new Error("stock onboarding requires a selected candidate managed-image catalog"); + } + + let descriptor: number | null = null; + try { + descriptor = fs.openSync(selected.path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + const metadata = fs.fstatSync(descriptor); + const pathMetadata = fs.lstatSync(selected.path); + if ( + pathMetadata.isSymbolicLink() || + !metadata.isFile() || + metadata.dev !== pathMetadata.dev || + metadata.ino !== pathMetadata.ino || + metadata.size < 2 || + metadata.size > 64 * 1024 + ) { + throw new Error(); + } + const parsed = JSON.parse(fs.readFileSync(descriptor, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(); + const catalog = parsed as Record; + if ( + JSON.stringify(Object.keys(catalog).sort()) !== + JSON.stringify([...SHIPPED_MANAGED_IMAGE_AGENTS].sort()) + ) { + throw new Error(); + } + + const contracts = new Map(); + let cohort: string | null = null; + let platform: ManagedImagePlatform | null = null; + let release: string | null = null; + for (const agent of SHIPPED_MANAGED_IMAGE_AGENTS) { + const contract = parseManagedImageContractV1(catalog[agent], agent); + cohort ??= contract.source.cohort; + platform ??= contract.platform; + release ??= contract.source.release; + if ( + contract.source.revision !== selected.revision || + contract.source.cohort !== cohort || + contract.platform !== platform || + contract.source.release !== release + ) { + throw new Error(); + } + contracts.set(agent, contract); + } + return contracts; + } catch { + throw new Error("stock onboarding candidate managed-image catalog is invalid"); + } finally { + if (descriptor !== null) fs.closeSync(descriptor); + } +} + +function selectedManagedImageRevision(environment: NodeJS.ProcessEnv): string { + const revision = environment.E2E_MANAGED_IMAGE_REVISION?.trim() ?? ""; + if (revision) { + if (!REVISION_PATTERN.test(revision)) { + throw new Error("stock onboarding requires one exact managed-image cohort revision"); + } + return revision; + } + const catalog = liveE2eManagedImageCatalog(environment); + if (!catalog) { + throw new Error("stock onboarding requires one exact managed-image cohort revision"); + } + return catalog.revision; +} + +export function assertManagedImageReceiptMatchesSelectedCohort(options: { + readonly environment: NodeJS.ProcessEnv; + readonly expectedAgent: ShippedManagedImageAgent; + readonly workload?: Record; +}): void { + const revision = options.environment.E2E_MANAGED_IMAGE_REVISION?.trim() ?? ""; + const rawReceipt = options.environment.E2E_MANAGED_IMAGE_COHORT_RECEIPT?.trim() ?? ""; + if (!revision) { + if (rawReceipt) { + throw new Error( + "stock onboarding requires the complete selected managed-image cohort receipt", + ); + } + const platform = options.workload?.platform; + if ( + typeof platform !== "string" || + !(MANAGED_IMAGE_PLATFORMS as readonly string[]).includes(platform) + ) { + throw new Error("stock onboarding candidate managed-image catalog is invalid"); + } + const contract = readCandidateCatalog(options.environment).get(options.expectedAgent); + if ( + !contract || + contract.platform !== platform || + options.workload?.kind !== "managed-image" || + options.workload.reference !== contract.reference || + options.workload.release !== contract.source.release || + options.workload.sourceRevision !== contract.source.revision || + options.workload.sourceCohort !== contract.source.cohort + ) { + throw new Error("stock onboarding must use the exact agent image from the selected cohort"); + } + return; + } + if (!REVISION_PATTERN.test(revision) || !rawReceipt || Buffer.byteLength(rawReceipt) > 8 * 1024) { + throw new Error("stock onboarding requires the complete selected managed-image cohort receipt"); + } + let receipt: Record; + try { + const parsed = JSON.parse(rawReceipt) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(); + receipt = parsed as Record; + } catch { + throw new Error("stock onboarding selected managed-image cohort receipt is invalid"); + } + const runId = receipt.runId; + const runAttempt = receipt.runAttempt; + const images = receipt.images; + const cohort = receipt.cohort; + if ( + JSON.stringify(Object.keys(receipt).sort()) !== + JSON.stringify(["cohort", "images", "kind", "revision", "runAttempt", "runId"]) || + receipt.kind !== "nemoclaw-managed-image-cohort-receipt-v1" || + receipt.revision !== revision || + !Number.isSafeInteger(runId) || + Number(runId) < 1 || + !Number.isSafeInteger(runAttempt) || + Number(runAttempt) < 1 || + cohort !== `ghrun-${String(runId)}-${String(runAttempt)}` || + !images || + typeof images !== "object" || + Array.isArray(images) || + JSON.stringify(Object.keys(images).sort()) !== + JSON.stringify([...SHIPPED_MANAGED_IMAGE_AGENTS].sort()) + ) { + throw new Error("stock onboarding selected managed-image cohort receipt is invalid"); + } + + const platform = options.workload?.platform; + const agentImages = (images as Record)[options.expectedAgent]; + if ( + typeof platform !== "string" || + !(MANAGED_IMAGE_PLATFORMS as readonly string[]).includes(platform) || + !agentImages || + typeof agentImages !== "object" || + Array.isArray(agentImages) || + JSON.stringify(Object.keys(agentImages).sort()) !== + JSON.stringify([...MANAGED_IMAGE_PLATFORMS].sort()) + ) { + throw new Error("stock onboarding selected managed-image cohort receipt is invalid"); + } + const expectedReference = (agentImages as Record)[platform]; + if ( + typeof expectedReference !== "string" || + !expectedReference.startsWith(`${MANAGED_IMAGE_REPOSITORIES[options.expectedAgent]}@sha256:`) || + options.workload?.kind !== "managed-image" || + options.workload.reference !== expectedReference || + options.workload.sourceRevision !== revision || + options.workload.sourceCohort !== cohort + ) { + throw new Error("stock onboarding must use the exact agent image from the selected cohort"); + } +} + +export interface StockManagedImageReceiptEvidence { + readonly agent: string; + readonly reference: string; + readonly sourceCohort: string; + readonly sourceRevision: string; +} + +function gatewayPort(environment: NodeJS.ProcessEnv): number { + const raw = environment.NEMOCLAW_GATEWAY_PORT?.trim(); + if (!raw) return DEFAULT_GATEWAY_PORT; + if (!/^[1-9][0-9]{0,4}$/u.test(raw)) { + throw new Error("stock managed-image receipt assertion requires a valid gateway port"); + } + const port = Number(raw); + if (!Number.isSafeInteger(port) || port > 65_535) { + throw new Error("stock managed-image receipt assertion requires a valid gateway port"); + } + return port; +} + +/** Assert the durable receipt before an E2E test begins post-onboarding probes. */ +export function assertStockManagedImageReceipt(options: { + readonly environment?: NodeJS.ProcessEnv; + readonly expectedAgent?: string; + readonly sandboxName: string; +}): StockManagedImageReceiptEvidence { + const environment = options.environment ?? process.env; + const revision = selectedManagedImageRevision(environment); + const home = environment.HOME?.trim() || os.homedir(); + const registryPath = path.join( + nemoclawStateRoot(home, gatewayPort(environment)), + "sandboxes.json", + ); + const registry = readConfigFile(registryPath, { sandboxes: {} }); + const sandboxes = + registry && typeof registry === "object" && !Array.isArray(registry) + ? (registry as { sandboxes?: unknown }).sandboxes + : undefined; + const entry = parseSandboxRegistryEntries(sandboxes).find( + ([name]) => name === options.sandboxName, + )?.[1]; + if (!entry) { + throw new Error(`stock sandbox '${options.sandboxName}' is missing from the durable registry`); + } + const authority = readManagedWorkloadAuthority(entry); + if (!authority) { + const receipt = cloneSandboxWorkloadReceipt(entry.workload); + throw new Error( + `stock sandbox '${options.sandboxName}' must record a managed-image receipt, got '${receipt?.kind ?? "missing"}'`, + ); + } + if (authority.receipt.sourceRevision !== revision) { + throw new Error( + `stock sandbox '${options.sandboxName}' managed-image revision does not match the selected cohort`, + ); + } + if (options.expectedAgent && authority.agent !== options.expectedAgent) { + throw new Error(`stock sandbox '${options.sandboxName}' managed-image agent does not match`); + } + if (isShippedManagedImageAgent(authority.agent)) { + assertManagedImageReceiptMatchesSelectedCohort({ + environment, + expectedAgent: authority.agent, + workload: authority.receipt as unknown as Record, + }); + } + return { + agent: authority.agent, + reference: authority.receipt.reference, + sourceCohort: authority.receipt.sourceCohort, + sourceRevision: authority.receipt.sourceRevision, + }; +} + +export function shouldAssertStockManagedImageReceipt( + command: string, + args: readonly string[], + environment: NodeJS.ProcessEnv, +): boolean { + const selectedRevision = environment.E2E_MANAGED_IMAGE_REVISION?.trim(); + const selectedCatalog = selectedRevision ? null : liveE2eManagedImageCatalog(environment); + if (!selectedRevision && !selectedCatalog) return false; + const selectedAgent = environment.NEMOCLAW_AGENT?.trim(); + if (selectedAgent && !isShippedManagedImageAgent(selectedAgent)) return false; + if (environment.NEMOCLAW_FROM_DOCKERFILE?.trim()) return false; + const executable = path.basename(command); + let onboardArgumentIndex = -1; + if (executable === "nemoclaw" || executable === "nemoclaw.js") { + onboardArgumentIndex = args[0] === "onboard" ? 0 : -1; + } + if (executable === "node" || executable === "nodejs") { + onboardArgumentIndex = + path.basename(args[0] ?? "") === "nemoclaw.js" && args[1] === "onboard" ? 1 : -1; + } + if (onboardArgumentIndex < 0) return false; + return !args + .slice(onboardArgumentIndex + 1) + .some((argument) => argument === "--from" || argument.startsWith("--from=")); +} diff --git a/test/e2e/fixtures/shell-probe.ts b/test/e2e/fixtures/shell-probe.ts index 929e30fd08d..0a66f223d35 100644 --- a/test/e2e/fixtures/shell-probe.ts +++ b/test/e2e/fixtures/shell-probe.ts @@ -5,7 +5,6 @@ import type { ArtifactSink } from "./artifacts.ts"; import { type ChildProcessProgress, spawnObservedChild } from "./observed-child-process.ts"; import { superviseChild } from "./shell/supervisor.ts"; import type { TrustedShellCommand } from "./shell/trusted-command.ts"; -import { resolveLiveE2eWorkloadSourceEnv } from "./workload-source-env.ts"; /** * Fixture-flavoured host shell probe. @@ -223,7 +222,7 @@ export class ShellProbe { spawn: { cwd: options.cwd, detached: true, - env: resolveLiveE2eWorkloadSourceEnv({ ...(options.env ?? {}) }), + env: { ...(options.env ?? {}) }, stdio: ["ignore", "pipe", "pipe"], }, }); diff --git a/test/e2e/fixtures/workload-source-env.ts b/test/e2e/fixtures/workload-source-env.ts deleted file mode 100644 index 23f363adc93..00000000000 --- a/test/e2e/fixtures/workload-source-env.ts +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import path from "node:path"; - -import { REPO_ROOT } from "./paths.ts"; - -const LEGACY_DOCKERFILE_BY_AGENT = { - openclaw: "Dockerfile", - hermes: "agents/hermes/Dockerfile", - "langchain-deepagents-code": "agents/langchain-deepagents-code/Dockerfile", -} as const; - -type LegacyDockerfileAgent = keyof typeof LEGACY_DOCKERFILE_BY_AGENT; - -function legacyDockerfileAgent(env: NodeJS.ProcessEnv): LegacyDockerfileAgent { - const agent = env.NEMOCLAW_AGENT ?? "openclaw"; - if (agent in LEGACY_DOCKERFILE_BY_AGENT) return agent as LegacyDockerfileAgent; - return "openclaw"; -} - -/** - * Existing live E2E targets retain the product's default legacy-Dockerfile - * path. Targets that must select a source explicitly do so through the - * provider-neutral E2E_WORKLOAD_SOURCE contract. - * - * This is applied at the final fixture spawn boundary so an agent selected by - * a test command receives its own Dockerfile rather than an OpenClaw default. - */ -export function resolveLiveE2eWorkloadSourceEnv(input: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const targetId = input.E2E_TARGET_ID; - const source = input.E2E_WORKLOAD_SOURCE; - if (!targetId || source !== "legacy-dockerfile") return input; - if (input.NEMOCLAW_FROM_DOCKERFILE) return input; - const agent = legacyDockerfileAgent(input); - return { - ...input, - NEMOCLAW_FROM_DOCKERFILE: path.join(REPO_ROOT, LEGACY_DOCKERFILE_BY_AGENT[agent]), - }; -} diff --git a/test/e2e/lib/fake-slack-api.cjs b/test/e2e/lib/fake-slack-api.cjs index 30b72a19eff..0935ff8f0d7 100755 --- a/test/e2e/lib/fake-slack-api.cjs +++ b/test/e2e/lib/fake-slack-api.cjs @@ -11,6 +11,8 @@ const http = require("http"); const host = process.env.FAKE_SLACK_API_HOST || "0.0.0.0"; const rawPort = process.env.FAKE_SLACK_API_PORT || "0"; const port = Number(rawPort); +const rawWebsocketPort = process.env.FAKE_SLACK_API_WEBSOCKET_PORT || "0"; +const websocketPort = Number(rawWebsocketPort); const portFile = process.env.FAKE_SLACK_API_PORT_FILE || ""; const captureFile = process.env.FAKE_SLACK_API_CAPTURE_FILE || ""; const expectedBotToken = process.env.FAKE_SLACK_API_EXPECTED_BOT_TOKEN || ""; @@ -26,6 +28,18 @@ if (!Number.isInteger(port) || port < 0 || port > 65535) { process.exit(2); } +if (!Number.isInteger(websocketPort) || websocketPort < 0 || websocketPort > 65535) { + console.error( + `FAKE_SLACK_API_WEBSOCKET_PORT must be an integer between 0 and 65535 (received: ${rawWebsocketPort})`, + ); + process.exit(2); +} + +if (port !== 0 && websocketPort === port) { + console.error("FAKE_SLACK_API_PORT and FAKE_SLACK_API_WEBSOCKET_PORT must be distinct"); + process.exit(2); +} + if (!expectedBotToken || !expectedAppToken) { console.error("FAKE_SLACK_API_EXPECTED_BOT_TOKEN and FAKE_SLACK_API_EXPECTED_APP_TOKEN are required"); process.exit(2); @@ -156,7 +170,7 @@ function sendSocketModeEvent(socket) { record({ event: "websocket-event-sent", path: "/socket-mode", envelopeId: envelope.envelope_id }); } -const server = http.createServer((req, res) => { +function handleRequest(req, res) { const chunks = []; let bodyBytes = 0; let bodyTooLarge = false; @@ -223,9 +237,9 @@ const server = http.createServer((req, res) => { }); res.end(JSON.stringify(response.body)); }); -}); +} -server.on("upgrade", (req, socket) => { +function handleUpgrade(req, socket) { const pathname = new URL(req.url || "/", "http://fake-slack.local").pathname; if (pathname !== "/socket-mode") { socket.destroy(); @@ -292,19 +306,39 @@ server.on("upgrade", (req, socket) => { } } }); -}); +} + +function createSlackServer() { + const slackServer = http.createServer(handleRequest); + slackServer.on("upgrade", handleUpgrade); + return slackServer; +} + +const restServer = createSlackServer(); +const websocketServer = createSlackServer(); +const listeningPorts = {}; -server.listen(port, host, () => { +function recordListening(kind, server) { const address = server.address(); - if (portFile) { - fs.writeFileSync(portFile, `${address.port}\n`, { mode: 0o600 }); + listeningPorts[kind] = address.port; + record({ event: "listening", host, kind, port: address.port }); + if (portFile && listeningPorts.rest && listeningPorts.websocket) { + fs.writeFileSync(portFile, `${listeningPorts.rest}\n`, { mode: 0o600 }); } - record({ event: "listening", host, port: address.port }); -}); +} + +restServer.listen(port, host, () => recordListening("rest", restServer)); +websocketServer.listen(websocketPort, host, () => recordListening("websocket", websocketServer)); for (const signal of ["SIGTERM", "SIGINT"]) { process.on(signal, () => { - server.close(() => process.exit(0)); + let openServers = 2; + const finish = () => { + openServers -= 1; + if (openServers === 0) process.exit(0); + }; + restServer.close(finish); + websocketServer.close(finish); setTimeout(() => process.exit(0), 1000).unref(); }); } diff --git a/test/e2e/live/bedrock-runtime-compatible-anthropic-raw-command.ts b/test/e2e/live/bedrock-runtime-compatible-anthropic-raw-command.ts index df248747f03..a98e1a76221 100644 --- a/test/e2e/live/bedrock-runtime-compatible-anthropic-raw-command.ts +++ b/test/e2e/live/bedrock-runtime-compatible-anthropic-raw-command.ts @@ -6,7 +6,6 @@ import { spawnObservedChild } from "../fixtures/observed-child-process.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; import type { TestProgress, TestProgressCapability } from "../fixtures/progress.ts"; import { redactString } from "../fixtures/redaction.ts"; -import { resolveLiveE2eWorkloadSourceEnv } from "../fixtures/workload-source-env.ts"; import { projectRawOutputForArtifact, type RawArtifactOutputMode, @@ -96,7 +95,7 @@ export async function runRawCommand( spawn: { cwd: options.cwd ?? REPO_ROOT, detached: true, - env: resolveLiveE2eWorkloadSourceEnv({ ...(options.env ?? {}) }), + env: { ...(options.env ?? {}) }, stdio: ["ignore", "pipe", "pipe"], }, }); diff --git a/test/e2e/live/cloud-onboard.test.ts b/test/e2e/live/cloud-onboard.test.ts index d54fdf3a619..5ea4a7808cf 100644 --- a/test/e2e/live/cloud-onboard.test.ts +++ b/test/e2e/live/cloud-onboard.test.ts @@ -16,6 +16,7 @@ import { } from "../fixtures/corporate-ca.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { requireHostedInferenceConfig } from "../fixtures/hosted-inference.ts"; +import { assertStockManagedImageReceipt } from "../fixtures/managed-image-receipt.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; @@ -221,6 +222,11 @@ test("cloud onboard: public installer creates healthy sandbox with security chec }, ); expect(install.exitCode, resultText(install)).toBe(0); + assertStockManagedImageReceipt({ + environment: testEnv(), + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }); expect(resultText(install)).toContain("Installing NemoClaw from GitHub"); expect(resultText(install)).toContain("Cloning NemoClaw source"); expect(resultText(install)).toContain( diff --git a/test/e2e/live/dashboard-connect-handoff.ts b/test/e2e/live/dashboard-connect-handoff.ts index e05dafd0cc1..23d07f60b1e 100644 --- a/test/e2e/live/dashboard-connect-handoff.ts +++ b/test/e2e/live/dashboard-connect-handoff.ts @@ -9,7 +9,6 @@ import { spawnObservedChild, } from "../fixtures/observed-child-process.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; -import { resolveLiveE2eWorkloadSourceEnv } from "../fixtures/workload-source-env.ts"; import { dashboardRemoteBindConnectStarted } from "./dashboard-remote-bind-env.ts"; const CONNECT_CAPTURE_LIMIT_BYTES = 1024 * 1024; @@ -88,7 +87,7 @@ export async function runDashboardConnectUntilForwardHandoff( spawn: { cwd: REPO_ROOT, detached: true, - env: resolveLiveE2eWorkloadSourceEnv({ ...options.env }), + env: { ...options.env }, stdio: ["ignore", "pipe", "pipe"], }, }); diff --git a/test/e2e/live/full-e2e-workload-evidence.ts b/test/e2e/live/full-e2e-workload-evidence.ts index 874734f5dfe..56df7bb6250 100644 --- a/test/e2e/live/full-e2e-workload-evidence.ts +++ b/test/e2e/live/full-e2e-workload-evidence.ts @@ -1,39 +1,25 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { readManagedWorkloadAuthority } from "../../../src/lib/onboard/workload/authority.ts"; -import { load as loadSandboxRegistry } from "../../../src/lib/state/registry/persistence.ts"; +import { assertStockManagedImageReceipt } from "../fixtures/managed-image-receipt.ts"; export function readFullE2eColdWorkloadEvidence( sandboxName: string, usedBuildKitPrebuild: boolean, + environment: NodeJS.ProcessEnv = process.env, ) { - const entry = loadSandboxRegistry().sandboxes[sandboxName]; - if (!entry) { - throw new Error(`full E2E sandbox '${sandboxName}' is missing from the registry`); - } - - const managedAuthority = readManagedWorkloadAuthority(entry); - if (managedAuthority) { - if (usedBuildKitPrebuild) { - throw new Error("managed-image cold onboarding must not use a local BuildKit prebuild"); - } - return { - kind: managedAuthority.receipt.kind, - reference: managedAuthority.receipt.reference, - sourceCohort: managedAuthority.receipt.sourceCohort, - sourceRevision: managedAuthority.receipt.sourceRevision, - } as const; - } - - if (entry.workload?.kind !== "legacy-dockerfile") { - throw new Error("full E2E cold onboarding must register a supported workload receipt"); - } - if (!usedBuildKitPrebuild) { - throw new Error("legacy Dockerfile cold onboarding must use the local BuildKit prebuild"); + if (usedBuildKitPrebuild) { + throw new Error("managed-image cold onboarding must not use a local BuildKit prebuild"); } + const receipt = assertStockManagedImageReceipt({ + environment, + expectedAgent: "openclaw", + sandboxName, + }); return { - kind: entry.workload.kind, - reference: entry.workload.reference, + kind: "managed-image", + reference: receipt.reference, + sourceCohort: receipt.sourceCohort, + sourceRevision: receipt.sourceRevision, } as const; } diff --git a/test/e2e/live/gateway-guard-recovery.test.ts b/test/e2e/live/gateway-guard-recovery.test.ts index 8df89fe17bb..24fa4d40974 100644 --- a/test/e2e/live/gateway-guard-recovery.test.ts +++ b/test/e2e/live/gateway-guard-recovery.test.ts @@ -471,6 +471,7 @@ test( timeoutMs: 120_000, }); expect(restart.exitCode, resultText(restart)).toBe(0); + await waitForSandboxExecReady(host, instance.sandboxName, progress, "restart-openshell-ready"); progress.phase("recover managed supervisor and inference"); const credentialCanary = "nemoclaw-e2e-recovery-secret-restart"; diff --git a/test/e2e/live/gpu-e2e.test.ts b/test/e2e/live/gpu-e2e.test.ts index 3ff9b1c5f68..ed73157f46a 100644 --- a/test/e2e/live/gpu-e2e.test.ts +++ b/test/e2e/live/gpu-e2e.test.ts @@ -357,6 +357,8 @@ test("GPU Ollama onboard enables CUDA, auth proxy, and sandbox inference", { messages: [{ role: "user", content: "Reply with exactly one word: PONG" }], // Keep this assertion about routed inference, not the model's reasoning-token budget. reasoning_effort: "none", + seed: 0, + temperature: 0, max_tokens: 32, }, )}'`, diff --git a/test/e2e/live/hermes-e2e.test.ts b/test/e2e/live/hermes-e2e.test.ts index 8b9875f9eea..28f34d0895d 100644 --- a/test/e2e/live/hermes-e2e.test.ts +++ b/test/e2e/live/hermes-e2e.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { HERMES_E2E_TEST_TIMEOUT_MS } from "../../../tools/e2e/hermes-timeout-contract.mts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import { assertStockManagedImageReceipt } from "../fixtures/managed-image-receipt.ts"; import { resultText, shellQuote } from "../fixtures/clients/command.ts"; import { trustedSandboxShellScript, validateSandboxName } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; @@ -336,6 +337,11 @@ test( ), )); expect(install.exitCode, resultText(install)).toBe(0); + assertStockManagedImageReceipt({ + environment: env, + expectedAgent: "hermes", + sandboxName: SANDBOX_NAME, + }); const cliProbe = await host.command( "bash", diff --git a/test/e2e/live/hermes-gpu-startup.test.ts b/test/e2e/live/hermes-gpu-startup.test.ts index d7ad88ec214..eba237e1ae6 100644 --- a/test/e2e/live/hermes-gpu-startup.test.ts +++ b/test/e2e/live/hermes-gpu-startup.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import { assertStockManagedImageReceipt } from "../fixtures/managed-image-receipt.ts"; import { cleanupUnlessVerified } from "../fixtures/cleanup-resources.ts"; import { type HostCliClient, @@ -457,6 +458,11 @@ test( ? captureFailedGpuContainer(host, gpuDiagnosticsDir) : Promise.resolve()); expect(install.exitCode, resultText(install)).toBe(0); + assertStockManagedImageReceipt({ + environment: env, + expectedAgent: "hermes", + sandboxName: SANDBOX_NAME, + }); const verifyFallback = async (wrapper: ReturnType) => { const fallbackEvents = readHermesGpuFallbackEvents(wrapper.eventsPath); diff --git a/test/e2e/live/jetson-nvmap-gpu.test.ts b/test/e2e/live/jetson-nvmap-gpu.test.ts index 352a045bd4f..24175d06ff6 100644 --- a/test/e2e/live/jetson-nvmap-gpu.test.ts +++ b/test/e2e/live/jetson-nvmap-gpu.test.ts @@ -288,7 +288,9 @@ fi`, // #3508 failed after Jetson onboarding silently fell back to building // Dockerfile.base locally. Prove this buildless path instead registered - // the dispatched, immutable published linux/arm64 managed image. + // the dispatched, immutable published linux/arm64 managed image. The v2 + // Jetson dispatch contract carries the exact revision, so bind its remote + // durable receipt to the immutable image's cohort label here. expect(resultText(install)).not.toContain( "Building OpenClaw sandbox base image locally because no compatible published base image was found.", ); @@ -298,11 +300,14 @@ fi`, "u", ); expect(managedImage.imageTag).toMatch(expectedReference); + const sourceCohort = managedImage.workload.sourceCohort; + expect(sourceCohort).toMatch(/^ghrun-[1-9][0-9]*-[1-9][0-9]*$/u); expect(managedImage.workload).toMatchObject({ kind: "managed-image", platform: "linux/arm64", reference: managedImage.imageTag, shared: true, + sourceCohort, sourceRevision: MANAGED_IMAGE_SOURCE_REVISION, }); const managedImageLabels = await host.command( @@ -319,6 +324,7 @@ fi`, expect(labels).toMatchObject({ "io.nvidia.nemoclaw.agent": "openclaw", "io.nvidia.nemoclaw.managed-image.capabilities": "1", + "io.nvidia.nemoclaw.managed-image.cohort": sourceCohort, "io.nvidia.nemoclaw.managed-image.contract": "1", "io.nvidia.nemoclaw.managed-image.platform": "linux/arm64", "io.nvidia.nemoclaw.managed-image.startup-profile": "1", diff --git a/test/e2e/live/mcp-bridge-onboard-env.ts b/test/e2e/live/mcp-bridge-onboard-env.ts index c75d35273f8..527f32609e1 100644 --- a/test/e2e/live/mcp-bridge-onboard-env.ts +++ b/test/e2e/live/mcp-bridge-onboard-env.ts @@ -1,7 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { ShippedManagedImageAgent } from "../../../src/lib/onboard/managed-image/contract.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import { assertManagedImageReceiptMatchesSelectedCohort } from "../fixtures/managed-image-receipt.ts"; const EXACT_MAIN_OVERLAY_KEYS = new Set([ "PATH", @@ -11,6 +13,8 @@ const EXACT_MAIN_OVERLAY_KEYS = new Set([ ]); const MCP_BRIDGE_QUALIFICATION_ENV_KEYS = [ + "E2E_MANAGED_IMAGE_REVISION", + "E2E_MANAGED_IMAGE_COHORT_RECEIPT", "NEMOCLAW_E2E_EXPECTED_SHA", "NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG", "NEMOCLAW_RUN_LIVE_E2E", @@ -24,9 +28,7 @@ const MCP_BRIDGE_ONBOARD_ARGS = [ "--yes-i-accept-third-party-software", ] as const; -export function buildMcpBridgeOnboardArgs( - environment: NodeJS.ProcessEnv = process.env, -): string[] { +export function buildMcpBridgeOnboardArgs(environment: NodeJS.ProcessEnv = process.env): string[] { const catalogPath = environment.NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG?.trim(); return catalogPath ? [ @@ -41,23 +43,23 @@ export function buildMcpBridgeOnboardArgs( export function assertMcpBridgeManagedImageReceipt(options: { environment?: NodeJS.ProcessEnv; + expectedAgent: ShippedManagedImageAgent; workload?: Record; }): void { const environment = options.environment ?? process.env; - if (!environment.NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG?.trim()) return; + const selectedRevision = environment.E2E_MANAGED_IMAGE_REVISION?.trim(); + const exactCandidateCatalog = environment.NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG?.trim(); + if (!selectedRevision && !exactCandidateCatalog) return; - const expectedRevision = environment.NEMOCLAW_E2E_EXPECTED_SHA?.trim() ?? ""; + const expectedRevision = selectedRevision ?? environment.NEMOCLAW_E2E_EXPECTED_SHA?.trim() ?? ""; if (!/^[0-9a-f]{40}$/u.test(expectedRevision)) { - throw new Error("managed-image MCP qualification requires an exact candidate revision"); - } - if ( - options.workload?.kind !== "managed-image" || - options.workload.sourceRevision !== expectedRevision - ) { - throw new Error( - "MCP qualification must use the exact managed image instead of a Dockerfile build", - ); + throw new Error("managed-image MCP qualification requires an exact cohort revision"); } + assertManagedImageReceiptMatchesSelectedCohort({ + environment, + expectedAgent: options.expectedAgent, + workload: options.workload, + }); } export function buildMcpBridgeExactMainEnv(options: { diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index c54a5ff5145..50b26fe2865 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -109,11 +109,12 @@ function mcpBridgeShardTest(shard: McpBridgeShard) { const test = mcpBridgeShardTest("openclaw"); type McpAgent = "openclaw" | "hermes" | "langchain-deepagents-code"; -function expectManagedImageQualificationReceipt(sandboxName: string): void { +function expectManagedImageQualificationReceipt(sandboxName: string, agent: McpAgent): void { const registry = JSON.parse(fs.readFileSync(REGISTRY_FILE, "utf8")) as { sandboxes?: Record }>; }; assertMcpBridgeManagedImageReceipt({ + expectedAgent: agent, workload: registry.sandboxes?.[sandboxName]?.workload, }); } @@ -164,7 +165,7 @@ async function onboardAgent( }), }); expectExitZero(result, `onboard ${options.agent} sandbox for MCP bridge`); - expectManagedImageQualificationReceipt(options.sandboxName); + expectManagedImageQualificationReceipt(options.sandboxName, options.agent); } async function assertSecretAbsentFromSandbox( sandbox: SandboxClient, diff --git a/test/e2e/live/messaging-providers-helpers.ts b/test/e2e/live/messaging-providers-helpers.ts index f93c4fb1a89..821c3e04aab 100644 --- a/test/e2e/live/messaging-providers-helpers.ts +++ b/test/e2e/live/messaging-providers-helpers.ts @@ -113,6 +113,7 @@ export function assertDiscordGatewayCapture(captureFile: string, expectedToken: export type FakeDockerApi = { kind: string; port: string; + alternatePort?: string; dir: string; captureFile: string; container: string; @@ -340,11 +341,13 @@ export async function runHost( artifactName: string; env: NodeJS.ProcessEnv; redactionValues: string[]; + cwd?: string; timeoutMs?: number; }, ): Promise { return host.command(command, args, { artifactName: options.artifactName, + cwd: options.cwd, env: options.env, redactionValues: options.redactionValues, timeoutMs: options.timeoutMs ?? PROBE_TIMEOUT_MS, @@ -628,6 +631,9 @@ export async function startFakeDockerApi( "-e", `${options.captureFileEnv}=/tmp/fake/capture.jsonl`, ]; + if (options.kind === "slack") { + dockerArgs.splice(7, 0, "-p", "0:8081", "-e", "FAKE_SLACK_API_WEBSOCKET_PORT=8081"); + } for (const [key, value] of Object.entries(options.expectedEnv)) { dockerArgs.push("-e", `${key}=${value}`); } @@ -667,15 +673,32 @@ export async function startFakeDockerApi( for (let attempt = 0; attempt < 100; attempt += 1) { if (fs.existsSync(portFile) && fs.statSync(portFile).size > 0) { - const port = await runHost(host, "docker", ["port", container, "8080/tcp"], { + const restPort = await runHost(host, "docker", ["port", container, "8080/tcp"], { artifactName: `port-fake-${options.kind}-api`, env: options.env, redactionValues: options.redactionValues, timeoutMs: 30_000, }); - const published = port.stdout.trim().split(":").at(-1)?.trim(); - if (published) { - return { kind: options.kind, port: published, dir, captureFile, container }; + const publishedRestPort = restPort.stdout.trim().split(":").at(-1)?.trim() ?? ""; + let publishedWebsocketPort = ""; + if (options.kind === "slack") { + const websocketPort = await runHost(host, "docker", ["port", container, "8081/tcp"], { + artifactName: "port-fake-slack-websocket-api", + env: options.env, + redactionValues: options.redactionValues, + timeoutMs: 30_000, + }); + publishedWebsocketPort = websocketPort.stdout.trim().split(":").at(-1)?.trim() ?? ""; + } + if (publishedRestPort && (options.kind !== "slack" || publishedWebsocketPort)) { + return { + kind: options.kind, + port: publishedRestPort, + ...(options.kind === "slack" ? { alternatePort: publishedWebsocketPort } : {}), + dir, + captureFile, + container, + }; } } await sleep(100); @@ -689,6 +712,7 @@ export async function applyRestRewritePolicy( api: FakeDockerApi, env: NodeJS.ProcessEnv, redactionValues: string[], + providerName?: string, ): Promise { const result = await runHost( host, @@ -717,6 +741,35 @@ export async function applyRestRewritePolicy( }, ); expectExitZero(result, `apply ${api.kind} fake REST policy`); + if (!providerName) return; + + const binding = await runHost( + host, + "bash", + [ + "-lc", + String.raw`set -eu +policy_file="$(mktemp)" +trap 'rm -f "$policy_file"' EXIT +"$1" policy get --base "$2" >"$policy_file" +node --import tsx "$5" "$policy_file" "$3" host.openshell.internal "$4" rest +"$1" policy set --policy "$policy_file" --wait "$2"`, + `bind-fake-${api.kind}-rest-policy`, + host.openshellCommandPath, + SANDBOX_NAME, + providerName, + api.port, + path.join(REPO_ROOT, "test/e2e/fixtures/hermes-discord-policy-binding.ts"), + ], + { + artifactName: `apply-${api.kind}-rest-policy-credential-binding`, + cwd: REPO_ROOT, + env, + redactionValues, + timeoutMs: 120_000, + }, + ); + expectExitZero(binding, `bind ${api.kind} fake REST policy credential`); } export async function applyWebSocketRewritePolicy( diff --git a/test/e2e/live/messaging-providers.test.ts b/test/e2e/live/messaging-providers.test.ts index 4a3daaa9376..adc6234a281 100644 --- a/test/e2e/live/messaging-providers.test.ts +++ b/test/e2e/live/messaging-providers.test.ts @@ -12,7 +12,8 @@ import fs from "node:fs"; import { testTimeoutOptions } from "../../helpers/timeouts"; -import { test } from "../fixtures/e2e-test.ts"; +import { expect, test } from "../fixtures/e2e-test.ts"; +import { assertStockManagedImageReceipt } from "../fixtures/managed-image-receipt.ts"; import { accountBool, accountString, @@ -166,6 +167,11 @@ test( return; } expectExitZero(install, "M0: install.sh completed"); + assertStockManagedImageReceipt({ + environment: state.env, + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }); const openshellVersion = await runHost(host, "openshell", ["--version"], { artifactName: "openshell-version-messaging-providers", @@ -831,7 +837,28 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); env: state.env, redactionValues, }); - await applyRestRewritePolicy(host, fakeSlack, state.env, redactionValues); + await applyRestRewritePolicy( + host, + fakeSlack, + state.env, + redactionValues, + `${SANDBOX_NAME}-slack-bridge`, + ); + expect( + fakeSlack.alternatePort, + "fake Slack API must publish an independent app-token port", + ).toMatch(/^[1-9][0-9]*$/u); + const fakeSlackApp = { + ...fakeSlack, + port: fakeSlack.alternatePort!, + }; + await applyRestRewritePolicy( + host, + fakeSlackApp, + state.env, + redactionValues, + `${SANDBOX_NAME}-slack-app`, + ); const slackAuth = await runSlackApiRequest( sandbox, @@ -883,7 +910,7 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); const slackApp = await runSlackApiRequest( sandbox, - fakeSlack.port, + fakeSlackApp.port, "/api/apps.connections.open", "Bearer xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", redactionValues, @@ -957,7 +984,13 @@ req.setTimeout(30000, () => { req.destroy(); console.log("TIMEOUT"); }); env: state.env, redactionValues, }); - await applyRestRewritePolicy(host, fakeTelegram, state.env, redactionValues); + await applyRestRewritePolicy( + host, + fakeTelegram, + state.env, + redactionValues, + `${SANDBOX_NAME}-telegram-bridge`, + ); const telegramMockTarget = "42424242"; const telegramMockText = "NemoClaw OpenClaw Telegram plugin mock E2E"; const installedTelegramProof = await runInstalledTelegramRuntimeProof( diff --git a/test/e2e/live/openclaw-pairing-helpers.ts b/test/e2e/live/openclaw-pairing-helpers.ts index 55c90556f9d..6f722aee0a4 100644 --- a/test/e2e/live/openclaw-pairing-helpers.ts +++ b/test/e2e/live/openclaw-pairing-helpers.ts @@ -450,7 +450,15 @@ function receiveSlackSocketEvent() { const appToken = parseManagedCredentialReference("SLACK_APP_TOKEN"); return new Promise((resolve, reject) => { const socket = proxy ? net.createConnection({ host: proxy.host, port: proxy.port }) : net.createConnection({ host, port }); - const timer = setTimeout(() => { socket.destroy(); reject(new Error("timed out waiting for fake Slack Socket Mode event")); }, 30000); + let settled = false; + const fail = (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + reject(error); + }; + const timer = setTimeout(() => fail(new Error("timed out waiting for fake Slack Socket Mode event")), 30000); let handshake = Buffer.alloc(0); let framed = Buffer.alloc(0); let upgraded = false; @@ -474,9 +482,7 @@ function receiveSlackSocketEvent() { if (end === -1) return; const statusLine = handshake.slice(0, end).toString("latin1").split("\r\n")[0] || ""; if (!statusLine.includes("101")) { - clearTimeout(timer); - socket.destroy(); - reject(new Error("fake Slack websocket upgrade failed: " + statusLine)); + fail(new Error("fake Slack websocket upgrade failed: " + statusLine)); return; } upgraded = true; @@ -489,9 +495,14 @@ function receiveSlackSocketEvent() { const frame = decodeServerFrame(framed); if (!frame) break; framed = framed.slice(frame.totalLength); + if (frame.opcode === 8) { + fail(new Error("fake Slack websocket closed before the Socket Mode event")); + return; + } if (frame.opcode !== 1) continue; const envelope = JSON.parse(frame.payload.toString("utf8")); socket.write(encodeClientText(JSON.stringify({ envelope_id: envelope.envelope_id }))); + settled = true; clearTimeout(timer); socket.end(); socket.destroy(); @@ -499,7 +510,8 @@ function receiveSlackSocketEvent() { return; } }); - socket.on("error", (error) => { clearTimeout(timer); reject(error); }); + socket.on("error", fail); + socket.on("close", () => fail(new Error("fake Slack websocket closed before the Socket Mode event"))); }); } function postPairingReply(text, channel) { diff --git a/test/e2e/live/openshell-credential-generation-window.test.ts b/test/e2e/live/openshell-credential-generation-window.test.ts index bbc9b811aae..5ffdefccb74 100644 --- a/test/e2e/live/openshell-credential-generation-window.test.ts +++ b/test/e2e/live/openshell-credential-generation-window.test.ts @@ -329,8 +329,8 @@ test("openshell-credential-generation-window", { "attach the MCP provider and observe its initial generation", "prove a retained credential generation expires", "rotate beyond the retained generation window", - "prove key removal and provider detach revoke access", - "restart the bridge and confirm old-process fallback", + "prove key and bridge removal revoke access", + "re-add the bridge and keep the old process revoked", "rebuild the sandbox and confirm credential reuse", "remove the MCP bridge and audit denied requests", ], @@ -677,7 +677,7 @@ test("openshell-credential-generation-window", { placeholderAbsent: true, }); - progress.phase("prove key removal and provider detach revoke access"); + progress.phase("prove key and bridge removal revoke access"); await updateProviderCredential( sandbox, providerName, @@ -736,16 +736,15 @@ test("openshell-credential-generation-window", { placeholderAbsent: true, }); - const detach = await sandbox.openshell( - ["sandbox", "provider", "detach", SANDBOX_NAME, providerName], + const removeBeforeReadd = await host.nemoclaw( + [SANDBOX_NAME, "mcp", "remove", SERVER_NAME], { - artifactName: "credential-window-direct-provider-detach", - env: openshellEnv(), - timeoutMs: 90_000, + artifactName: "credential-window-remove-before-readd", + env: buildAvailabilityProbeEnv(), + timeoutMs: 4 * 60_000, }, ); - expectExitZero(detach, "detach credential-window provider"); - expect(resultText(detach)).toMatch(/Detached provider/iu); + expectExitZero(removeBeforeReadd, "remove credential-window bridge before re-add"); await expectFreshCredentialAbsent( sandbox, "credential-window-fresh-credential-absent-after-detach", @@ -765,45 +764,66 @@ test("openshell-credential-generation-window", { ).seen, ).toBe(false); - progress.phase("restart the bridge and confirm old-process fallback"); - await rotateCredential( - host, - fakeMcp, - restartSecret, - CREDENTIAL_WINDOW_ROTATION_COUNT + 1, - allSecrets, + progress.phase("re-add the bridge and keep the old process revoked"); + fakeMcp.setSecret(restartSecret); + const readd = await host.nemoclaw( + [ + SANDBOX_NAME, + "mcp", + "add", + SERVER_NAME, + "--url", + tunnel.url, + "--env", + CREDENTIAL_WINDOW_ENV_NAME, + ], + { + artifactName: "credential-window-readd-after-removal", + env: { + ...buildAvailabilityProbeEnv(), + [CREDENTIAL_WINDOW_ENV_NAME]: restartSecret, + }, + redactionValues: [...allSecrets], + timeoutMs: 4 * 60_000, + }, ); + expectExitZero(readd, "re-add credential-window bridge"); restartedRevision = await observeFreshRevision( sandbox, "credential-window-fresh-revision-after-restart", ); expect(restartedRevision).not.toBe(currentRevision); expect(restartedRevision).not.toBe(restoredKeyRevision); + const freshAfterReaddId = `${CREDENTIAL_WINDOW_REQUEST_PREFIX}:fresh-after-readd`; + const freshAfterReadd = await runFreshRequest( + sandbox, + tunnel.url, + freshAfterReaddId, + allSecrets, + "credential-window-fresh-request-after-readd", + ); + expect(freshAfterReadd).toEqual({ + revision: restartedRevision, + status: 200, + }); + expect(requestEvidence(fakeMcp, freshAfterReaddId, restartSecret)).toEqual({ + seen: true, + credentialRewritten: true, + placeholderAbsent: true, + }); await writeControl( sandbox, - CREDENTIAL_WINDOW_STEPS.fallbackAfterRestart, - "credential-window-signal-fallback-after-restart", + CREDENTIAL_WINDOW_STEPS.deniedAfterReadd, + "credential-window-signal-denied-after-readd", ); - await waitForAcknowledgement(sandbox, CREDENTIAL_WINDOW_STEPS.fallbackAfterRestart, "allowed"); - await expect - .poll( - () => - requestEvidence( - fakeMcp, - credentialWindowRequestId(CREDENTIAL_WINDOW_STEPS.fallbackAfterRestart), - restartSecret, - ), - { - interval: 500, - timeout: 30_000, - message: "old revision fallback after restart", - }, - ) - .toEqual({ - seen: true, - credentialRewritten: true, - placeholderAbsent: true, - }); + await waitForAcknowledgement(sandbox, CREDENTIAL_WINDOW_STEPS.deniedAfterReadd, "denied"); + expect( + requestEvidence( + fakeMcp, + credentialWindowRequestId(CREDENTIAL_WINDOW_STEPS.deniedAfterReadd), + restartSecret, + ).seen, + ).toBe(false); } finally { await writeControl( sandbox, @@ -831,8 +851,8 @@ test("openshell-credential-generation-window", { { step: CREDENTIAL_WINDOW_STEPS.deniedAfterKeyRemoval, outcome: "denied" }, { step: CREDENTIAL_WINDOW_STEPS.deniedAfterDetach, outcome: "denied" }, { - step: CREDENTIAL_WINDOW_STEPS.fallbackAfterRestart, - outcome: "allowed", + step: CREDENTIAL_WINDOW_STEPS.deniedAfterReadd, + outcome: "denied", }, ], }); @@ -906,6 +926,9 @@ test("openshell-credential-generation-window", { expect(upstreamRequestIds).not.toContain( credentialWindowRequestId(CREDENTIAL_WINDOW_STEPS.deniedAfterDetach), ); + expect(upstreamRequestIds).not.toContain( + credentialWindowRequestId(CREDENTIAL_WINDOW_STEPS.deniedAfterReadd), + ); expect( fakeMcp.requests.every( (request: CredentialWindowRequest) => !request.auth.includes("openshell:resolve:env"), diff --git a/test/e2e/live/openshell-credential-generation-window.ts b/test/e2e/live/openshell-credential-generation-window.ts index 5d56d44993c..db5065cf0be 100644 --- a/test/e2e/live/openshell-credential-generation-window.ts +++ b/test/e2e/live/openshell-credential-generation-window.ts @@ -24,7 +24,7 @@ export const CREDENTIAL_WINDOW_STEPS = { fallbackAfterEviction: "fallback-after-eviction", deniedAfterKeyRemoval: "denied-after-key-removal", deniedAfterDetach: "denied-after-detach", - fallbackAfterRestart: "fallback-after-restart", + deniedAfterReadd: "denied-after-readd", stop: "stop", } as const; @@ -34,7 +34,7 @@ export type CredentialWindowRequestStep = | (typeof CREDENTIAL_WINDOW_STEPS)["fallbackAfterEviction"] | (typeof CREDENTIAL_WINDOW_STEPS)["deniedAfterKeyRemoval"] | (typeof CREDENTIAL_WINDOW_STEPS)["deniedAfterDetach"] - | (typeof CREDENTIAL_WINDOW_STEPS)["fallbackAfterRestart"]; + | (typeof CREDENTIAL_WINDOW_STEPS)["deniedAfterReadd"]; export function credentialWindowSecret(generation: number): string { return `${MCP_BRIDGE_TEST_CREDENTIALS.generationWindow}${String(generation).padStart(2, "0")}`; @@ -108,7 +108,7 @@ const requestSteps = new Set([ config.steps.fallbackAfterEviction, config.steps.deniedAfterKeyRemoval, config.steps.deniedAfterDetach, - config.steps.fallbackAfterRestart, + config.steps.deniedAfterReadd, ]); const seen = new Set(); const outcomes = []; diff --git a/test/e2e/support/base-image-publication-workflow-boundary.test.ts b/test/e2e/support/base-image-publication-workflow-boundary.test.ts index e7956a52a26..5260d0ae127 100644 --- a/test/e2e/support/base-image-publication-workflow-boundary.test.ts +++ b/test/e2e/support/base-image-publication-workflow-boundary.test.ts @@ -69,10 +69,12 @@ function gateStep(value: MutableWorkflow, name: string): MutableStep { } function runClassifier(environment: { + baseSha?: string; checkoutSha: string; eventName: string; ref: string; repository: string; + workflowSha?: string; }): { output: string; status: number | null } { const source = required( gateSteps(workflow())[0]?.run, @@ -84,11 +86,13 @@ function runClassifier(environment: { const result = spawnSync("/bin/bash", ["-c", source], { encoding: "utf8", env: { + BASE_SHA: environment.baseSha ?? "b".repeat(40), CHECKOUT_SHA: environment.checkoutSha, EVENT_NAME: environment.eventName, GITHUB_OUTPUT: outputPath, REF: environment.ref, REPOSITORY: environment.repository, + WORKFLOW_SHA: environment.workflowSha ?? "c".repeat(40), }, }); return { @@ -108,14 +112,15 @@ describe("base-image publication workflow boundary (#7372)", () => { }); it.each([ - ["push to main", "push", "", "refs/heads/main", "1", "0"], - ["manual main", "workflow_dispatch", "", "refs/heads/main", "1", "0"], + ["push to main", "push", "", "refs/heads/main", "0", "c".repeat(40), "0"], + ["manual main", "workflow_dispatch", "", "refs/heads/main", "0", "c".repeat(40), "0"], [ "controller-selected PR", "workflow_dispatch", "a".repeat(40), "refs/heads/candidate", - "0", + "1", + "b".repeat(40), "1", ], [ @@ -123,12 +128,13 @@ describe("base-image publication workflow boundary (#7372)", () => { "workflow_dispatch", "a4f9b59aa64f88532a3e64e949dd1b4068aa1f1e", "refs/heads/candidate", - "0", + "1", + "b".repeat(40), "1", ], ])( "classifies %s without executing untrusted code (#7372)", - (_case, eventName, checkoutSha, ref, required, reuse) => { + (_case, eventName, checkoutSha, ref, allowNonHead, expectedSha, selectNearest) => { expect( runClassifier({ checkoutSha, @@ -136,7 +142,10 @@ describe("base-image publication workflow boundary (#7372)", () => { ref, repository: "NVIDIA/NemoClaw", }), - ).toEqual({ output: `required=${required}\nreuse=${reuse}\n`, status: 0 }); + ).toEqual({ + output: `allow_non_head=${allowNonHead}\nexpected_sha=${expectedSha}\nselect_nearest_successful=${selectNearest}\n`, + status: 0, + }); }, ); @@ -180,7 +189,10 @@ describe("base-image publication workflow boundary (#7372)", () => { [ "classifier outcome", (value) => { - gateSteps(value)[0].run = gateSteps(value)[0].run!.replace("required=1", "required=0"); + gateSteps(value)[0].run = gateSteps(value)[0].run!.replace( + "select_nearest_successful=0", + "select_nearest_successful=1", + ); }, ], ["checkout condition", (value) => (gateSteps(value)[1].if = "${{ always() }}")], @@ -194,59 +206,20 @@ describe("base-image publication workflow boundary (#7372)", () => { ["Node condition", (value) => (gateSteps(value)[2].if = "${{ always() }}")], ["Node pin", (value) => (gateSteps(value)[2].uses = "actions/setup-node@v6")], ["Node version", (value) => (gateSteps(value)[2].with!["node-version"] = 20)], - [ - "pairing condition", - (value) => - (gateStep( - value, - "Resolve Deep Agents Code base publication for the exact PR managed image", - ).if = "${{ always() }}"), - ], - [ - "pairing catalog", - (value) => - (gateStep( - value, - "Resolve Deep Agents Code base publication for the exact PR managed image", - ).env!["MANAGED_IMAGE_CATALOG"] = "${{ inputs.catalog }}"), - ], - [ - "pairing verifier", - (value) => - (gateStep( - value, - "Resolve Deep Agents Code base publication for the exact PR managed image", - ).run = "node unreviewed.mts"), - ], - [ - "verifier condition", - (value) => - (gateStep(value, "Verify applicable base-image publication").if = "${{ always() }}"), - ], - [ - "verifier token", - (value) => - (gateStep(value, "Verify applicable base-image publication").env!.GITHUB_TOKEN = - "${{ secrets.TOKEN }}"), - ], + ["verifier condition", (value) => (gateSteps(value)[3].if = "${{ always() }}")], + ["verifier token", (value) => (gateSteps(value)[3].env!.GITHUB_TOKEN = "${{ secrets.TOKEN }}")], [ "verifier SHA", - (value) => - (gateStep(value, "Verify applicable base-image publication").env!.EXPECTED_SHA = - "${{ inputs.checkout_sha }}"), + (value) => (gateSteps(value)[3].env!.EXPECTED_SHA = "${{ inputs.checkout_sha }}"), ], [ "managed-image publication requirement", - (value) => - (gateStep(value, "Verify applicable base-image publication").env![ - "REQUIRE_MANAGED_IMAGE_PUBLICATION" - ] = "0"), + (value) => (gateSteps(value)[3].env!.REQUIRE_MANAGED_IMAGE_PUBLICATION = "0"), ], [ "verifier command", (value) => { - gateStep(value, "Verify applicable base-image publication").run = - "node tools/e2e/base-image-publication.mts"; + gateSteps(value)[3].run = "node tools/e2e/base-image-publication.mts"; }, ], [ @@ -269,10 +242,9 @@ describe("base-image publication workflow boundary (#7372)", () => { "node tools/e2e/dcode-base-image-contract.mts contract.json"), ], ["step count", (value) => gateSteps(value).push({ name: "Unreviewed step", run: "true" })], - ["publication matrix dependency", (value) => delete value.jobs["base-image-publication"].needs], [ "matrix publication dependency", - (value) => (value.jobs["generate-matrix"].needs = "base-image-publication"), + (value) => (value.jobs["generate-matrix"].needs = []), ], ["live publication dependency", (value) => (value.jobs.live.needs = ["generate-matrix"])], [ diff --git a/test/e2e/support/base-image-publication.test.ts b/test/e2e/support/base-image-publication.test.ts index a1ed7f41ef4..8b713fe8373 100644 --- a/test/e2e/support/base-image-publication.test.ts +++ b/test/e2e/support/base-image-publication.test.ts @@ -345,6 +345,7 @@ describe("base-image publication evidence", () => { it("binds API evidence to the active checked-in workflow identity (#7372)", () => { expect(validateWorkflow(workflowMetadata())).toBe(WORKFLOW_ID); + expect(validateWorkflow(workflowMetadata({ name: "Images / Base Images" }))).toBe(WORKFLOW_ID); expect(() => validateWorkflow(workflowMetadata({ state: "disabled_manually" }))).toThrow( /state must be active/u, ); @@ -480,6 +481,62 @@ describe("base-image publication evidence", () => { }); }); + it("selects the nearest fully successful trusted run for branch reuse", () => { + const failedRunId = RUN_ID + 1; + const selection = selectPublicationRun( + runsPayload([ + workflowRun({ + id: failedRunId, + head_sha: DESCENDANT_SHA, + conclusion: "failure", + html_url: `${RUN_URL_ROOT}/${failedRunId}`, + }), + workflowRun(), + ]), + history(), + WORKFLOW_ID, + { completedSuccessOnly: true }, + ); + + expect(selection).toMatchObject({ + state: "selected", + run: { id: RUN_ID, headSha: RELEVANT_SHA, conclusion: "success" }, + }); + }); + + it("accepts the renamed trusted workflow while selecting branch reuse", () => { + const selection = selectPublicationRun( + runsPayload([workflowRun({ name: "Images / Base Images" })]), + history(), + WORKFLOW_ID, + { completedSuccessOnly: true }, + ); + + expect(selection).toMatchObject({ + state: "selected", + run: { id: RUN_ID, headSha: RELEVANT_SHA, conclusion: "success" }, + }); + }); + + it("does not select an incomplete or failed publication for branch reuse", () => { + expect( + selectPublicationRun( + runsPayload([ + workflowRun({ status: "in_progress", conclusion: null }), + workflowRun({ + id: RUN_ID + 1, + head_sha: DESCENDANT_SHA, + conclusion: "failure", + html_url: `${RUN_URL_ROOT}/${RUN_ID + 1}`, + }), + ]), + history(), + WORKFLOW_ID, + { completedSuccessOnly: true }, + ), + ).toEqual({ state: "missing" }); + }); + it("ignores pre-rename workflow metadata outside the eligible history (#7372)", () => { const selection = selectPublicationRun( runsPayload([ @@ -505,7 +562,7 @@ describe("base-image publication evidence", () => { history(), WORKFLOW_ID, ), - ).toThrow(/name must be Images \/ Publish Base and Managed Images/u); + ).toThrow(/name must be one of Images \/ Publish Base and Managed Images, Images \/ Base Images/u); }); it("selects an in-progress trusted publication run (#9549)", () => { @@ -576,6 +633,16 @@ describe("base-image publication evidence", () => { ).toThrow(/provenance does not match/u); }); + it("accepts the renamed trusted publisher jobs", () => { + const jobs = [ + publisherJob("Manifests / OpenClaw", { id: 1 }), + publisherJob("Manifests / Hermes", { id: 2 }), + publisherJob("Manifests / Deep Agents Code", { id: 3 }), + ]; + + expect(validatePublisherJobs({ total_count: jobs.length, jobs }, selectedRun())).toBe("ready"); + }); + it("classifies an incomplete required publisher as pending only while the selected run is in progress (#9549)", () => { const jobs = successfulJobs().map((job) => job.name === "Build and push Hermes base image" diff --git a/test/e2e/support/bedrock-runtime-compatible-anthropic-progress.test.ts b/test/e2e/support/bedrock-runtime-compatible-anthropic-progress.test.ts index a70a95dee41..228d6faad16 100644 --- a/test/e2e/support/bedrock-runtime-compatible-anthropic-progress.test.ts +++ b/test/e2e/support/bedrock-runtime-compatible-anthropic-progress.test.ts @@ -8,7 +8,6 @@ import path from "node:path"; import { afterEach, describe, expect, it, onTestFinished } from "vitest"; import { ArtifactSink } from "../fixtures/artifacts.ts"; -import { REPO_ROOT } from "../fixtures/paths.ts"; import { startTestProgress } from "../fixtures/progress.ts"; import { SNAPSHOT_DATA_PREFIX } from "../live/bedrock-runtime-compatible-anthropic-leaks.ts"; @@ -55,28 +54,6 @@ afterEach(async () => { }); describe("Bedrock raw-command progress", () => { - it("applies the provider-neutral workload source at the raw spawn boundary", async () => { - const artifacts = await artifactSink("bedrock-workload-source"); - const observation = progressProbe(); - const result = await runRawCommand( - process.execPath, - ["-e", "process.stdout.write(process.env.NEMOCLAW_FROM_DOCKERFILE ?? '')"], - { - artifactName: "bedrock-workload-source", - artifacts, - env: { - E2E_TARGET_ID: "inference-routing", - E2E_WORKLOAD_SOURCE: "legacy-dockerfile", - NEMOCLAW_AGENT: "langchain-deepagents-code", - }, - progress: observation.progress, - }, - ); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(path.join(REPO_ROOT, "agents/langchain-deepagents-code/Dockerfile")); - }); - it("reports timestamp-only output activity without forwarding child payloads", async () => { const secret = "opaque-bedrock-progress-secret"; const artifacts = await artifactSink("bedrock-progress-output"); diff --git a/test/e2e/support/exact-artifact-download.test.ts b/test/e2e/support/exact-artifact-download.test.ts index c6d83782481..4e7577f7c8b 100644 --- a/test/e2e/support/exact-artifact-download.test.ts +++ b/test/e2e/support/exact-artifact-download.test.ts @@ -13,7 +13,9 @@ import { bindNamedExactArtifact, downloadBoundArtifact, exactArtifactName, + exactManagedImageCohortArtifactName, materializeContractArchive, + materializeExactJsonArchive, type BoundArtifactIdentity, type ExactArtifactExpectation, } from "../../../tools/e2e/exact-artifact-download.mts"; @@ -93,6 +95,19 @@ describe("exact artifact download (#9340)", () => { }); }); + it("derives and materializes the bound managed-image cohort artifact", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cohort-artifact-")); + const bytes = artifactZip([{ name: "cohort.json", contents: '{"contractVersion":2}\n' }]); + try { + expect(exactManagedImageCohortArtifactName(EXPECTED)).toBe("managed-image-cohort-7001-2"); + const cohortPath = materializeExactJsonArchive(bytes, directory, "cohort.json"); + expect(JSON.parse(fs.readFileSync(cohortPath, "utf8"))).toEqual({ contractVersion: 2 }); + expect(fs.statSync(cohortPath).mode & 0o777).toBe(0o600); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + it.each([ ["expired", { expired: true }, "non-expired"], ["artifact id URL", { id: 9002 }, "archive URL does not match artifact id"], diff --git a/test/e2e/support/fixtures/slack-forward-proxy.ts b/test/e2e/support/fixtures/slack-forward-proxy.ts new file mode 100644 index 00000000000..cac51d004cb --- /dev/null +++ b/test/e2e/support/fixtures/slack-forward-proxy.ts @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crypto from "node:crypto"; +import net from "node:net"; +import vm from "node:vm"; + +import { SLACK_PAIRING_SCRIPT } from "../../live/openclaw-pairing-helpers.ts"; + +export function createSlackSocketClient(proxyPort: number, targetPort: number) { + const sourceStart = SLACK_PAIRING_SCRIPT.indexOf("function parseFakeSlackPort"); + const sourceEnd = SLACK_PAIRING_SCRIPT.indexOf("function postPairingReply"); + if (sourceStart < 0 || sourceEnd <= sourceStart) { + throw new Error("Slack Socket Mode client source is missing"); + } + const source = SLACK_PAIRING_SCRIPT.slice(sourceStart, sourceEnd); + return vm.runInNewContext(`${source}\nreceiveSlackSocketEvent`, { + Buffer, + URL, + clearTimeout, + crypto, + net: { + createConnection: () => net.createConnection({ host: "127.0.0.1", port: proxyPort }), + }, + process: { + env: { + FAKE_SLACK_WEBSOCKET_PORT: String(targetPort), + HTTP_PROXY: "http://10.200.0.1:3128", + SLACK_APP_TOKEN: "openshell:resolve:env:v42_SLACK_APP_TOKEN", + http_proxy: "", + }, + }, + setTimeout, + }) as () => Promise>; +} + +function encodeServerText(payload: Record): Buffer { + const body = Buffer.from(JSON.stringify(payload), "utf8"); + if (body.length > 125) throw new Error("test WebSocket payload is too large"); + return Buffer.concat([Buffer.from([0x81, body.length]), body]); +} + +function decodeClientText(buffer: Buffer): { payload: string; totalLength: number } | null { + if (buffer.length < 6 || (buffer[0] & 0x0f) !== 1 || (buffer[1] & 0x80) === 0) return null; + let payloadLength = buffer[1] & 0x7f; + let offset = 2; + if (payloadLength === 126) { + if (buffer.length < 8) return null; + payloadLength = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLength === 127) { + if (buffer.length < 14) return null; + const longLength = buffer.readBigUInt64BE(2); + if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) + throw new Error("test WebSocket payload is too large"); + payloadLength = Number(longLength); + offset = 10; + } + const totalLength = offset + 4 + payloadLength; + if (buffer.length < totalLength) return null; + const mask = buffer.subarray(offset, offset + 4); + const body = Buffer.from(buffer.subarray(offset + 4, totalLength)); + for (let index = 0; index < body.length; index += 1) body[index] ^= mask[index % 4]; + return { payload: body.toString("utf8"), totalLength }; +} + +export async function listenOnLoopback(server: net.Server): Promise { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("test proxy has no TCP port"); + return address.port; +} + +export async function closeServer(server: net.Server): Promise { + await new Promise((resolve) => server.close(() => resolve())); +} + +export function createSuccessfulSlackForwardProxy(envelope: Record): { + server: net.Server; + requests: string[]; + websocketMessages: () => string[]; +} { + const requests: string[] = []; + const websocketMessages: string[] = []; + const server = net.createServer((socket) => { + let buffer = Buffer.alloc(0); + let upgraded = false; + socket.on("data", (chunk) => { + if (upgraded) { + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + while (buffer.length > 0) { + const frame = decodeClientText(buffer); + if (!frame) return; + buffer = buffer.subarray(frame.totalLength); + websocketMessages.push(frame.payload); + if (websocketMessages.length === 1) socket.write(encodeServerText(envelope)); + } + return; + } + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + const end = buffer.indexOf("\r\n\r\n"); + if (end === -1) return; + requests.push(buffer.slice(0, end).toString("latin1")); + upgraded = true; + buffer = buffer.subarray(end + 4); + socket.write( + Buffer.from( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n", + "latin1", + ), + ); + }); + }); + return { + server, + requests, + websocketMessages: () => websocketMessages, + }; +} + +export function createRejectedSlackForwardProxy(): net.Server { + return net.createServer((socket) => { + socket.once("data", () => { + socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); + }); + }); +} diff --git a/test/e2e/support/inference-adapter.test.ts b/test/e2e/support/inference-adapter.test.ts index a07a5f993e4..aa7ffdf6286 100644 --- a/test/e2e/support/inference-adapter.test.ts +++ b/test/e2e/support/inference-adapter.test.ts @@ -380,7 +380,16 @@ describe("E2E inference adapter", () => { env: { NEMOCLAW_E2E_INFERENCE_MODE: "public-nvidia" }, secrets: { NVIDIA_API_KEY: "sk-compatible-key" }, }), - ).rejects.toThrow(/must start with nvapi-/); + ).rejects.toThrow(/NVIDIA_API_KEY must start with nvapi-/); + }); + + it("does not treat the internal NVIDIA inference credential as public authority", async () => { + await expect( + createAdapter({ + env: { NEMOCLAW_E2E_INFERENCE_MODE: "public-nvidia" }, + secrets: { NVIDIA_INFERENCE_API_KEY: "nvapi-wrong-source" }, + }), + ).rejects.toThrow(/missing NVIDIA_API_KEY/); }); it("does not use the historical runtime input as the public NVIDIA source secret", async () => { diff --git a/test/e2e/support/launch-agent-turn.test.ts b/test/e2e/support/launch-agent-turn.test.ts index 53cb57400d8..01a3e5c9fdf 100644 --- a/test/e2e/support/launch-agent-turn.test.ts +++ b/test/e2e/support/launch-agent-turn.test.ts @@ -421,6 +421,7 @@ if (process.argv[2] !== "tui") { }) + "\n"; client.end(body); }); + process.umask(0o177); await new Promise((resolve, reject) => { replacement.once("error", reject); replacement.listen(socketPath, resolve); diff --git a/test/e2e/support/managed-image-cohort-contract.test.ts b/test/e2e/support/managed-image-cohort-contract.test.ts new file mode 100644 index 00000000000..92599eab8a1 --- /dev/null +++ b/test/e2e/support/managed-image-cohort-contract.test.ts @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + MANAGED_IMAGE_REPOSITORIES, + SHIPPED_MANAGED_IMAGE_AGENTS, +} from "../../../src/lib/onboard/managed-image/contract"; +import { validateManagedImageCohort } from "../../../tools/e2e/managed-image-cohort-contract.mts"; + +const REVISION = "a".repeat(40); +const RUN_ID = 32707920950; +const RUN_ATTEMPT = 1; +const COHORT = `ghrun-${RUN_ID}-${RUN_ATTEMPT}`; +const PLATFORMS = ["linux/amd64", "linux/arm64"] as const; +type JsonObject = Record; +type PlatformPublication = { + publicationEvidence: { + workloadDescriptor: JsonObject; + attestations: { + manifestDescriptor: { annotations: JsonObject }; + slsa: { statement: { bindings: JsonObject; subject: JsonObject } }; + spdx: { statement: { subject: JsonObject } }; + }; + }; +}; + +function digest(index: number): `sha256:${string}` { + return `sha256:${(index % 15).toString(16).repeat(64)}`; +} + +function cohortContract(): Record { + return { + contractVersion: 2, + cohort: COHORT, + source: { repository: "NVIDIA/NemoClaw", revision: REVISION, release: null }, + run: { id: RUN_ID, attempt: RUN_ATTEMPT }, + platforms: PLATFORMS, + agents: Object.fromEntries( + SHIPPED_MANAGED_IMAGE_AGENTS.map((agent, agentIndex) => { + const image = MANAGED_IMAGE_REPOSITORIES[agent]; + const manifestDigest = digest(agentIndex + 1); + return [ + agent, + { + image, + digest: manifestDigest, + reference: `${image}@${manifestDigest}`, + descriptor: { digest: manifestDigest }, + alias: `${image}:cohort-${COHORT}`, + platforms: Object.fromEntries( + PLATFORMS.map((platform, platformIndex) => { + const platformDigest = digest(agentIndex + platformIndex + 4); + const workloadDigest = digest(agentIndex + platformIndex + 10); + const baseReference = `ghcr.io/nvidia/nemoclaw/base@${digest(agentIndex + platformIndex + 7)}`; + const [os, architecture] = platform.split("/"); + return [ + platform, + { + digest: platformDigest, + reference: `${image}@${platformDigest}`, + baseReference, + publicationEvidence: { + candidateDescriptor: { + digest: platformDigest, + mediaType: "application/vnd.oci.image.index.v1+json", + size: 100, + }, + workloadDescriptor: { + digest: workloadDigest, + mediaType: "application/vnd.oci.image.manifest.v1+json", + platform: { os, architecture }, + size: 200, + }, + attestations: { + manifestDescriptor: { + annotations: { + "vnd.docker.reference.digest": workloadDigest, + "vnd.docker.reference.type": "attestation-manifest", + }, + digest: digest(agentIndex + platformIndex + 12), + mediaType: "application/vnd.oci.image.manifest.v1+json", + platform: { os: "unknown", architecture: "unknown" }, + size: 300, + }, + slsa: { + descriptor: { + annotations: { + "in-toto.io/predicate-type": "https://slsa.dev/provenance/v1", + }, + digest: digest(agentIndex + platformIndex + 13), + mediaType: "application/vnd.in-toto+json", + size: 400, + }, + statement: { + type: "https://in-toto.io/Statement/v1", + predicateType: "https://slsa.dev/provenance/v1", + buildType: + "https://github.com/moby/buildkit/blob/master/docs/attestations/slsa-definitions.md", + builderId: `https://github.com/NVIDIA/NemoClaw/actions/runs/${RUN_ID}/attempts/${RUN_ATTEMPT}`, + subject: { name: image, digest: workloadDigest }, + bindings: { + agent, + baseReference, + cohort: COHORT, + platform, + revision: REVISION, + source: "https://github.com/NVIDIA/NemoClaw", + }, + }, + }, + spdx: { + descriptor: { + annotations: { + "in-toto.io/predicate-type": "https://spdx.dev/Document", + }, + digest: digest(agentIndex + platformIndex + 14), + mediaType: "application/vnd.in-toto+json", + size: 500, + }, + statement: { + type: "https://in-toto.io/Statement/v1", + predicateType: "https://spdx.dev/Document", + subject: { name: image, digest: workloadDigest }, + }, + }, + }, + }, + }, + ]; + }), + ), + }, + ]; + }), + ), + }; +} + +function platformPublication(value: Record): PlatformPublication { + const agents = value.agents as Record }>; + return agents.openclaw.platforms["linux/amd64"]; +} + +describe("managed-image cohort publication contract", () => { + it("binds all shipped agents and architectures to the selected publication", () => { + expect( + validateManagedImageCohort(cohortContract(), { + revision: REVISION, + runAttempt: RUN_ATTEMPT, + runId: RUN_ID, + }), + ).toEqual({ + cohort: COHORT, + receipt: { + kind: "nemoclaw-managed-image-cohort-receipt-v1", + cohort: COHORT, + revision: REVISION, + runAttempt: RUN_ATTEMPT, + runId: RUN_ID, + images: Object.fromEntries( + SHIPPED_MANAGED_IMAGE_AGENTS.map((agent, agentIndex) => [ + agent, + Object.fromEntries( + PLATFORMS.map((platform, platformIndex) => [ + platform, + `${MANAGED_IMAGE_REPOSITORIES[agent]}@${digest(agentIndex + platformIndex + 10)}`, + ]), + ), + ]), + ), + }, + revision: REVISION, + runAttempt: RUN_ATTEMPT, + runId: RUN_ID, + }); + }); + + it("rejects a cohort that omits one image architecture", () => { + const value = cohortContract(); + const agents = value.agents as Record }>; + delete agents.hermes.platforms["linux/arm64"]; + + expect(() => + validateManagedImageCohort(value, { + revision: REVISION, + runAttempt: RUN_ATTEMPT, + runId: RUN_ID, + }), + ).toThrow("complete expected set"); + }); + + it("rejects mixed revision provenance within the cohort", () => { + const value = cohortContract(); + const agents = value.agents as Record< + string, + { + platforms: Record< + string, + { + publicationEvidence: { + attestations: { slsa: { statement: { bindings: JsonObject } } }; + }; + } + >; + } + >; + agents.openclaw.platforms[ + "linux/amd64" + ].publicationEvidence.attestations.slsa.statement.bindings.revision = "b".repeat(40); + + expect(() => + validateManagedImageCohort(value, { + revision: REVISION, + runAttempt: RUN_ATTEMPT, + runId: RUN_ID, + }), + ).toThrow("revision must be"); + }); + + it.each([ + [ + "a missing attestation manifest workload digest binding", + (publication: PlatformPublication) => + delete publication.publicationEvidence.attestations.manifestDescriptor.annotations[ + "vnd.docker.reference.digest" + ], + ], + [ + "a changed attestation manifest workload digest binding", + (publication: PlatformPublication) => + (publication.publicationEvidence.attestations.manifestDescriptor.annotations[ + "vnd.docker.reference.digest" + ] = digest(9)), + ], + [ + "a missing SLSA subject workload digest binding", + (publication: PlatformPublication) => + delete publication.publicationEvidence.attestations.slsa.statement.subject.digest, + ], + [ + "a changed SLSA subject workload digest binding", + (publication: PlatformPublication) => + (publication.publicationEvidence.attestations.slsa.statement.subject.digest = digest(9)), + ], + [ + "a missing SPDX subject workload digest binding", + (publication: PlatformPublication) => + delete publication.publicationEvidence.attestations.spdx.statement.subject.digest, + ], + [ + "a changed SPDX subject workload digest binding", + (publication: PlatformPublication) => + (publication.publicationEvidence.attestations.spdx.statement.subject.digest = digest(9)), + ], + ])("rejects %s", (_label, corruptBinding) => { + const value = cohortContract(); + corruptBinding(platformPublication(value)); + + expect(() => + validateManagedImageCohort(value, { + revision: REVISION, + runAttempt: RUN_ATTEMPT, + runId: RUN_ID, + }), + ).toThrow("workload digest must be"); + }); +}); diff --git a/test/e2e/support/managed-image-receipt.test.ts b/test/e2e/support/managed-image-receipt.test.ts new file mode 100644 index 00000000000..5cc0e85ccfd --- /dev/null +++ b/test/e2e/support/managed-image-receipt.test.ts @@ -0,0 +1,430 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { managedStartupE2eProfile } from "../../../scripts/checks/generate-managed-startup-profile-fixture.mts"; +import { + MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, + MANAGED_IMAGE_CONTRACT_VERSION, + MANAGED_IMAGE_REPOSITORIES, + MANAGED_IMAGE_SOURCE_REPOSITORY, + MANAGED_IMAGE_STARTUP_PROFILE_CONTRACT_VERSION, + SHIPPED_MANAGED_IMAGE_AGENTS, +} from "../../../src/lib/onboard/managed-image/contract.ts"; +import { encodeManagedStartupProfile } from "../../../src/lib/onboard/managed-startup/profile.ts"; +import { nemoclawStateRoot } from "../../../src/lib/state/state-root.ts"; +import { + assertStockManagedImageReceipt, + shouldAssertStockManagedImageReceipt, +} from "../fixtures/managed-image-receipt.ts"; +import { readFullE2eColdWorkloadEvidence } from "../live/full-e2e-workload-evidence.ts"; + +const SANDBOX_NAME = "managed-only-stock"; +const REVISION = "d".repeat(40); +const COHORT = "ghrun-32707920950-1"; +const REFERENCE = `${MANAGED_IMAGE_REPOSITORIES.openclaw}@sha256:${"a".repeat(64)}`; +const CATALOG_REFERENCES = { + openclaw: REFERENCE, + hermes: `${MANAGED_IMAGE_REPOSITORIES.hermes}@sha256:${"c".repeat(64)}`, + "langchain-deepagents-code": `${MANAGED_IMAGE_REPOSITORIES["langchain-deepagents-code"]}@sha256:${"e".repeat(64)}`, +} as const; +const temporaryHomes: string[] = []; + +afterEach(() => { + for (const home of temporaryHomes.splice(0)) { + fs.rmSync(home, { force: true, recursive: true }); + } +}); + +function managedReceipt(sourceRevision = REVISION): Record { + const encodedProfile = encodeManagedStartupProfile(managedStartupE2eProfile("openclaw")); + return { + schemaVersion: 1, + kind: "managed-image", + reference: REFERENCE, + platform: "linux/amd64", + release: "v0.0.100", + sourceRevision, + sourceCohort: COHORT, + capabilityContractVersion: MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, + startupProfileContractVersion: MANAGED_IMAGE_STARTUP_PROFILE_CONTRACT_VERSION, + encodedProfile, + startupProfileSha256: createHash("sha256").update(encodedProfile, "utf8").digest("hex"), + credentialProxyReplayRequired: false, + shared: true, + }; +} + +function selectedEnvironment(home: string): NodeJS.ProcessEnv { + return { + E2E_MANAGED_IMAGE_REVISION: REVISION, + E2E_MANAGED_IMAGE_COHORT_RECEIPT: JSON.stringify({ + kind: "nemoclaw-managed-image-cohort-receipt-v1", + cohort: COHORT, + revision: REVISION, + runAttempt: 1, + runId: 32707920950, + images: { + openclaw: { + "linux/amd64": REFERENCE, + "linux/arm64": `${MANAGED_IMAGE_REPOSITORIES.openclaw}@sha256:${"b".repeat(64)}`, + }, + hermes: { + "linux/amd64": `${MANAGED_IMAGE_REPOSITORIES.hermes}@sha256:${"c".repeat(64)}`, + "linux/arm64": `${MANAGED_IMAGE_REPOSITORIES.hermes}@sha256:${"d".repeat(64)}`, + }, + "langchain-deepagents-code": { + "linux/amd64": `${MANAGED_IMAGE_REPOSITORIES["langchain-deepagents-code"]}@sha256:${"e".repeat(64)}`, + "linux/arm64": `${MANAGED_IMAGE_REPOSITORIES["langchain-deepagents-code"]}@sha256:${"f".repeat(64)}`, + }, + }, + }), + HOME: home, + }; +} + +function candidateCatalogEnvironment(home: string): NodeJS.ProcessEnv { + const catalog = Object.fromEntries( + SHIPPED_MANAGED_IMAGE_AGENTS.map((agent) => { + const reference = CATALOG_REFERENCES[agent]; + return [ + agent, + { + agent, + capabilityContractVersion: MANAGED_IMAGE_CAPABILITY_CONTRACT_VERSION, + contractVersion: MANAGED_IMAGE_CONTRACT_VERSION, + digest: reference.slice(reference.indexOf("@") + 1), + image: MANAGED_IMAGE_REPOSITORIES[agent], + platform: "linux/amd64", + reference, + source: { + cohort: COHORT, + release: "v0.0.100", + repository: MANAGED_IMAGE_SOURCE_REPOSITORY, + revision: REVISION, + }, + startupProfileContractVersion: MANAGED_IMAGE_STARTUP_PROFILE_CONTRACT_VERSION, + }, + ]; + }), + ); + const catalogPath = path.join(home, "candidate-managed-image-catalog.json"); + fs.writeFileSync(catalogPath, `${JSON.stringify(catalog)}\n`, "utf8"); + return { + GITHUB_ACTIONS: "true", + HOME: home, + NEMOCLAW_E2E_EXPECTED_SHA: REVISION, + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: catalogPath, + NEMOCLAW_RUN_LIVE_E2E: "1", + }; +} + +function writeRegistry(workload: Record): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-only-receipt-")); + temporaryHomes.push(home); + const stateRoot = nemoclawStateRoot(home, 8080); + fs.mkdirSync(stateRoot, { recursive: true }); + fs.writeFileSync( + path.join(stateRoot, "sandboxes.json"), + `${JSON.stringify({ + sandboxes: { + [SANDBOX_NAME]: { + name: SANDBOX_NAME, + agent: "openclaw", + fromDockerfile: null, + imageTag: workload.reference, + workload, + }, + }, + })}\n`, + "utf8", + ); + return home; +} + +describe("stock E2E managed-image receipt assertion", () => { + it("accepts the durable receipt from the selected cohort revision", () => { + const home = writeRegistry(managedReceipt()); + + expect( + assertStockManagedImageReceipt({ + environment: selectedEnvironment(home), + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toMatchObject({ agent: "openclaw", sourceRevision: REVISION }); + }); + + it("accepts the durable receipt from the trusted candidate catalog", () => { + const home = writeRegistry(managedReceipt()); + + expect( + assertStockManagedImageReceipt({ + environment: candidateCatalogEnvironment(home), + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toMatchObject({ agent: "openclaw", sourceRevision: REVISION }); + }); + + it("uses the trusted candidate catalog for full E2E workload evidence", () => { + const home = writeRegistry(managedReceipt()); + + expect( + readFullE2eColdWorkloadEvidence(SANDBOX_NAME, false, candidateCatalogEnvironment(home)), + ).toMatchObject({ kind: "managed-image", sourceRevision: REVISION }); + }); + + it("rejects a candidate catalog whose source revision differs from the exact candidate revision", () => { + const home = writeRegistry(managedReceipt()); + const environment = candidateCatalogEnvironment(home); + const catalogPath = environment.NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG!; + const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8")) as Record< + string, + { source: { revision: string } } + >; + catalog.openclaw!.source.revision = "b".repeat(40); + catalog.hermes!.source.revision = "b".repeat(40); + catalog["langchain-deepagents-code"]!.source.revision = "b".repeat(40); + fs.writeFileSync(catalogPath, `${JSON.stringify(catalog)}\n`, "utf8"); + + expect(() => + assertStockManagedImageReceipt({ + environment, + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toThrow("candidate managed-image catalog is invalid"); + }); + + it("rejects a candidate catalog that mixes releases", () => { + const home = writeRegistry(managedReceipt()); + const environment = candidateCatalogEnvironment(home); + const catalogPath = environment.NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG!; + const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8")) as Record< + string, + { source: { release: string } } + >; + catalog.hermes!.source.release = "v0.0.101"; + fs.writeFileSync(catalogPath, `${JSON.stringify(catalog)}\n`, "utf8"); + + expect(() => + assertStockManagedImageReceipt({ + environment, + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toThrow("candidate managed-image catalog is invalid"); + }); + + it("rejects a candidate catalog with an extra agent", () => { + const home = writeRegistry(managedReceipt()); + const environment = candidateCatalogEnvironment(home); + const catalogPath = environment.NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG!; + const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8")) as Record; + catalog.extra = catalog.openclaw; + fs.writeFileSync(catalogPath, `${JSON.stringify(catalog)}\n`, "utf8"); + + expect(() => + assertStockManagedImageReceipt({ + environment, + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toThrow("candidate managed-image catalog is invalid"); + }); + + it("rejects a candidate catalog that exceeds the size limit", () => { + const home = writeRegistry(managedReceipt()); + const environment = candidateCatalogEnvironment(home); + fs.writeFileSync( + environment.NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG!, + JSON.stringify({ padding: "x".repeat(64 * 1024) }), + "utf8", + ); + + expect(() => + assertStockManagedImageReceipt({ + environment, + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toThrow("candidate managed-image catalog is invalid"); + }); + + it("rejects a durable workload release that differs from the candidate catalog", () => { + const home = writeRegistry({ ...managedReceipt(), release: "v0.0.101" }); + + expect(() => + assertStockManagedImageReceipt({ + environment: candidateCatalogEnvironment(home), + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toThrow("exact agent image from the selected cohort"); + }); + + it("does not replace a missing selected-cohort receipt with the candidate catalog", () => { + const home = writeRegistry(managedReceipt()); + const environment = candidateCatalogEnvironment(home); + environment.E2E_MANAGED_IMAGE_REVISION = REVISION; + + expect(() => + assertStockManagedImageReceipt({ + environment, + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toThrow("complete selected managed-image cohort receipt"); + }); + + it("does not replace a missing selected revision with the candidate catalog", () => { + const home = writeRegistry(managedReceipt()); + const environment = candidateCatalogEnvironment(home); + environment.E2E_MANAGED_IMAGE_COHORT_RECEIPT = + selectedEnvironment(home).E2E_MANAGED_IMAGE_COHORT_RECEIPT; + + expect(() => + assertStockManagedImageReceipt({ + environment, + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toThrow("complete selected managed-image cohort receipt"); + }); + + it("rejects a stock legacy Dockerfile receipt", () => { + const home = writeRegistry({ + schemaVersion: 1, + kind: "legacy-dockerfile", + reference: "stock-legacy:latest", + shared: false, + }); + + expect(() => + assertStockManagedImageReceipt({ + environment: { E2E_MANAGED_IMAGE_REVISION: REVISION, HOME: home }, + sandboxName: SANDBOX_NAME, + }), + ).toThrow("must record a managed-image receipt"); + }); + + it("rejects a managed receipt from another cohort revision", () => { + const home = writeRegistry(managedReceipt("b".repeat(40))); + + expect(() => + assertStockManagedImageReceipt({ + environment: { E2E_MANAGED_IMAGE_REVISION: REVISION, HOME: home }, + sandboxName: SANDBOX_NAME, + }), + ).toThrow("does not match the selected cohort"); + }); + + it("rejects the selected revision with another publication cohort", () => { + const home = writeRegistry({ ...managedReceipt(), sourceCohort: "ghrun-999-1" }); + + expect(() => + assertStockManagedImageReceipt({ + environment: selectedEnvironment(home), + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toThrow("exact agent image from the selected cohort"); + }); + + it("rejects the selected revision with another immutable image reference", () => { + const home = writeRegistry({ + ...managedReceipt(), + reference: `${MANAGED_IMAGE_REPOSITORIES.openclaw}@sha256:${"9".repeat(64)}`, + }); + + expect(() => + assertStockManagedImageReceipt({ + environment: selectedEnvironment(home), + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toThrow("exact agent image from the selected cohort"); + }); + + it("rejects a cohort receipt that maps the stock agent to another repository", () => { + const home = writeRegistry(managedReceipt()); + const environment = selectedEnvironment(home); + const receipt = JSON.parse(environment.E2E_MANAGED_IMAGE_COHORT_RECEIPT!) as { + images: Record>; + }; + receipt.images.openclaw["linux/amd64"] = + `${MANAGED_IMAGE_REPOSITORIES.hermes}@sha256:${"c".repeat(64)}`; + environment.E2E_MANAGED_IMAGE_COHORT_RECEIPT = JSON.stringify(receipt); + + expect(() => + assertStockManagedImageReceipt({ + environment, + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toThrow("exact agent image from the selected cohort"); + }); + + it("rejects a platform reference that differs from the durable workload", () => { + const home = writeRegistry({ ...managedReceipt(), platform: "linux/arm64" }); + + expect(() => + assertStockManagedImageReceipt({ + environment: selectedEnvironment(home), + expectedAgent: "openclaw", + sandboxName: SANDBOX_NAME, + }), + ).toThrow("exact agent image from the selected cohort"); + }); + + it("asserts normal stock onboarding and excludes an explicit custom Dockerfile", () => { + expect( + shouldAssertStockManagedImageReceipt("/workspace/bin/nemoclaw.js", ["onboard"], { + E2E_MANAGED_IMAGE_REVISION: REVISION, + }), + ).toBe(true); + expect( + shouldAssertStockManagedImageReceipt("/workspace/bin/nemoclaw.js", ["onboard"], { + E2E_MANAGED_IMAGE_REVISION: REVISION, + NEMOCLAW_FROM_DOCKERFILE: "/workspace/CustomDockerfile", + }), + ).toBe(false); + const home = writeRegistry(managedReceipt()); + expect( + shouldAssertStockManagedImageReceipt( + "/workspace/bin/nemoclaw.js", + ["onboard"], + candidateCatalogEnvironment(home), + ), + ).toBe(true); + const nonLiveEnvironment = candidateCatalogEnvironment(home); + nonLiveEnvironment.GITHUB_ACTIONS = "false"; + expect( + shouldAssertStockManagedImageReceipt( + "/workspace/bin/nemoclaw.js", + ["onboard"], + nonLiveEnvironment, + ), + ).toBe(false); + expect( + shouldAssertStockManagedImageReceipt( + "/workspace/bin/nemoclaw.js", + ["onboard", "--from", "/workspace/CustomDockerfile"], + { E2E_MANAGED_IMAGE_REVISION: REVISION }, + ), + ).toBe(false); + expect( + shouldAssertStockManagedImageReceipt("/workspace/bin/nemoclaw.js", ["onboard"], { + E2E_MANAGED_IMAGE_REVISION: REVISION, + NEMOCLAW_AGENT: "pi", + }), + ).toBe(false); + }); +}); diff --git a/test/e2e/support/mcp-bridge-onboard-env.test.ts b/test/e2e/support/mcp-bridge-onboard-env.test.ts index d11ebc60a41..04d17737eea 100644 --- a/test/e2e/support/mcp-bridge-onboard-env.test.ts +++ b/test/e2e/support/mcp-bridge-onboard-env.test.ts @@ -1,8 +1,17 @@ // 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 { + MANAGED_IMAGE_REPOSITORIES, + SHIPPED_MANAGED_IMAGE_AGENTS, +} from "../../../src/lib/onboard/managed-image/contract.ts"; + import { assertMcpBridgeManagedImageReceipt, buildMcpBridgeExactMainEnv, @@ -19,6 +28,48 @@ const ONBOARD_OPTIONS = { endpointUrl: "https://inference.example.test/v1", sandboxName: "e2e-mcp-dcode", }; +const SELECTED_REVISION = "c".repeat(40); +const SELECTED_COHORT = "ghrun-123-4"; +const PLATFORM = "linux/amd64"; +const selectedReferences = Object.fromEntries( + SHIPPED_MANAGED_IMAGE_AGENTS.map((agent, agentIndex) => [ + agent, + Object.fromEntries( + ["linux/amd64", "linux/arm64"].map((platform, platformIndex) => [ + platform, + `${MANAGED_IMAGE_REPOSITORIES[agent]}@sha256:${String(agentIndex + platformIndex + 1).repeat(64)}`, + ]), + ), + ]), +) as Record>; + +function selectedEnvironment(): NodeJS.ProcessEnv { + return { + E2E_MANAGED_IMAGE_REVISION: SELECTED_REVISION, + E2E_MANAGED_IMAGE_COHORT_RECEIPT: JSON.stringify({ + kind: "nemoclaw-managed-image-cohort-receipt-v1", + cohort: SELECTED_COHORT, + revision: SELECTED_REVISION, + runAttempt: 4, + runId: 123, + images: selectedReferences, + }), + }; +} + +function selectedWorkload( + agent: keyof typeof MANAGED_IMAGE_REPOSITORIES, + overrides: Record = {}, +): Record { + return { + kind: "managed-image", + platform: PLATFORM, + reference: selectedReferences[agent][PLATFORM], + sourceCohort: SELECTED_COHORT, + sourceRevision: SELECTED_REVISION, + ...overrides, + }; +} describe("MCP bridge onboarding environment", () => { it("restores exact-main OpenShell overrides after child environment sanitization", () => { @@ -71,25 +122,23 @@ describe("MCP bridge onboarding environment", () => { it("rejects a Dockerfile workload in managed-image MCP qualification", () => { expect(() => assertMcpBridgeManagedImageReceipt({ - environment: { - NEMOCLAW_E2E_EXPECTED_SHA: "a".repeat(40), - NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: "/tmp/managed-pr-catalog.json", - }, - workload: { kind: "dockerfile" }, + environment: selectedEnvironment(), + expectedAgent: "langchain-deepagents-code", + workload: selectedWorkload("langchain-deepagents-code", { kind: "dockerfile" }), }), - ).toThrow("must use the exact managed image instead of a Dockerfile build"); + ).toThrow("must use the exact agent image from the selected cohort"); }); it("rejects a managed image from a different candidate revision", () => { expect(() => assertMcpBridgeManagedImageReceipt({ - environment: { - NEMOCLAW_E2E_EXPECTED_SHA: "a".repeat(40), - NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: "/tmp/managed-pr-catalog.json", - }, - workload: { kind: "managed-image", sourceRevision: "b".repeat(40) }, + environment: selectedEnvironment(), + expectedAgent: "langchain-deepagents-code", + workload: selectedWorkload("langchain-deepagents-code", { + sourceRevision: "b".repeat(40), + }), }), - ).toThrow("must use the exact managed image instead of a Dockerfile build"); + ).toThrow("must use the exact agent image from the selected cohort"); }); it("activates the exact managed runtime when the qualification catalog is present", () => { @@ -109,17 +158,128 @@ describe("MCP bridge onboarding environment", () => { }); it("accepts the exact managed image candidate revision", () => { + const revision = "a".repeat(40); + const cohort = "ghrun-77-2"; + const reference = `${MANAGED_IMAGE_REPOSITORIES["langchain-deepagents-code"]}@sha256:${"d".repeat(64)}`; + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-catalog-")); + const catalogPath = path.join(fixtureRoot, "catalog.json"); + fs.writeFileSync( + catalogPath, + JSON.stringify( + Object.fromEntries( + SHIPPED_MANAGED_IMAGE_AGENTS.map((agent, index) => { + const digest = + agent === "langchain-deepagents-code" + ? `sha256:${"d".repeat(64)}` + : `sha256:${String(index + 1).repeat(64)}`; + return [ + agent, + { + contractVersion: 1, + agent, + platform: PLATFORM, + image: MANAGED_IMAGE_REPOSITORIES[agent], + digest, + reference: + agent === "langchain-deepagents-code" + ? reference + : `${MANAGED_IMAGE_REPOSITORIES[agent]}@${digest}`, + source: { + repository: "NVIDIA/NemoClaw", + revision, + release: "v0.0.114", + cohort, + }, + startupProfileContractVersion: 1, + capabilityContractVersion: 1, + }, + ]; + }), + ), + ), + { mode: 0o600 }, + ); + try { + expect(() => + assertMcpBridgeManagedImageReceipt({ + environment: { + GITHUB_ACTIONS: "true", + NEMOCLAW_E2E_EXPECTED_SHA: revision, + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: catalogPath, + NEMOCLAW_RUN_LIVE_E2E: "1", + }, + expectedAgent: "langchain-deepagents-code", + workload: { + kind: "managed-image", + platform: PLATFORM, + reference, + release: "v0.0.114", + sourceCohort: cohort, + sourceRevision: revision, + }, + }), + ).not.toThrow(); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("rejects a symbolic-link candidate catalog", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-mcp-catalog-link-")); + const targetPath = path.join(fixtureRoot, "catalog.json"); + const linkPath = path.join(fixtureRoot, "selected.json"); + fs.writeFileSync(targetPath, "{}", { mode: 0o600 }); + fs.symlinkSync(targetPath, linkPath); + try { + expect(() => + assertMcpBridgeManagedImageReceipt({ + environment: { + GITHUB_ACTIONS: "true", + NEMOCLAW_E2E_EXPECTED_SHA: "a".repeat(40), + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: linkPath, + NEMOCLAW_RUN_LIVE_E2E: "1", + }, + expectedAgent: "langchain-deepagents-code", + workload: selectedWorkload("langchain-deepagents-code"), + }), + ).toThrow("candidate managed-image catalog is invalid"); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("accepts the selected cross-release managed-image cohort revision", () => { expect(() => assertMcpBridgeManagedImageReceipt({ - environment: { - NEMOCLAW_E2E_EXPECTED_SHA: "a".repeat(40), - NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: "/tmp/managed-pr-catalog.json", - }, - workload: { kind: "managed-image", sourceRevision: "a".repeat(40) }, + environment: selectedEnvironment(), + expectedAgent: "langchain-deepagents-code", + workload: selectedWorkload("langchain-deepagents-code"), }), ).not.toThrow(); }); + it("rejects a different agent image from the selected cohort", () => { + expect(() => + assertMcpBridgeManagedImageReceipt({ + environment: selectedEnvironment(), + expectedAgent: "langchain-deepagents-code", + workload: selectedWorkload("openclaw"), + }), + ).toThrow("must use the exact agent image from the selected cohort"); + }); + + it("rejects a different publication cohort with the selected revision", () => { + expect(() => + assertMcpBridgeManagedImageReceipt({ + environment: selectedEnvironment(), + expectedAgent: "langchain-deepagents-code", + workload: selectedWorkload("langchain-deepagents-code", { + sourceCohort: "ghrun-999-1", + }), + }), + ).toThrow("must use the exact agent image from the selected cohort"); + }); + it("passes only exact-main OpenShell overrides after fixed onboarding values", () => { const env = buildMcpBridgeOnboardEnv({ ...ONBOARD_OPTIONS, diff --git a/test/e2e/support/mcp-workflow-boundary.test.ts b/test/e2e/support/mcp-workflow-boundary.test.ts index cc1bcfc2ae0..30d2b3e135c 100644 --- a/test/e2e/support/mcp-workflow-boundary.test.ts +++ b/test/e2e/support/mcp-workflow-boundary.test.ts @@ -101,7 +101,7 @@ describe("MCP workflow artifact boundary", () => { expect(validateMcpOpenShellWorkflowBoundary(workflowPath)).toEqual( expect.arrayContaining([ "mcp-bridge must not serialize the independent credential generation-window proof", - "openshell-credential-generation-window must depend only on matrix generation so it can run in parallel", + "openshell-credential-generation-window must depend on publication and matrix generation", ]), ); } finally { diff --git a/test/e2e/support/messaging-providers-runtime-proofs.test.ts b/test/e2e/support/messaging-providers-runtime-proofs.test.ts index 5528f3c6c2b..9a5952c445c 100644 --- a/test/e2e/support/messaging-providers-runtime-proofs.test.ts +++ b/test/e2e/support/messaging-providers-runtime-proofs.test.ts @@ -24,6 +24,11 @@ import { import { TELEGRAM_INSTALLED_RUNTIME_PROOF_SOURCE } from "../live/messaging-providers-telegram-runtime-proof.ts"; const FAKE_TELEGRAM_API = path.resolve(import.meta.dirname, "../lib/fake-telegram-api.cjs"); +const FAKE_SLACK_API = path.resolve(import.meta.dirname, "../lib/fake-slack-api.cjs"); +const MESSAGING_PROVIDERS_HELPERS_SOURCE = fs.readFileSync( + path.resolve(import.meta.dirname, "../live/messaging-providers-helpers.ts"), + "utf8", +); const LIVE_MESSAGING_PROVIDERS_SOURCE = fs.readFileSync( path.resolve(import.meta.dirname, "../live/messaging-providers.test.ts"), "utf8", @@ -48,6 +53,55 @@ async function waitFor(predicate: () => boolean, message: string): Promise } describe("messaging provider installed-runtime proofs", () => { + it("publishes independent fake Slack REST and websocket ports", async () => { + expect(MESSAGING_PROVIDERS_HELPERS_SOURCE.match(/"0:8080"/gu)).toHaveLength(1); + expect(MESSAGING_PROVIDERS_HELPERS_SOURCE).toContain('"0:8081"'); + expect(MESSAGING_PROVIDERS_HELPERS_SOURCE).toContain('"FAKE_SLACK_API_WEBSOCKET_PORT=8081"'); + expect(MESSAGING_PROVIDERS_HELPERS_SOURCE).toContain('["port", container, "8081/tcp"]'); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-slack-ports-")); + const portFile = path.join(dir, "port"); + const captureFile = path.join(dir, "capture.jsonl"); + const child = spawn(process.execPath, [FAKE_SLACK_API], { + env: { + ...process.env, + FAKE_SLACK_API_HOST: "127.0.0.1", + FAKE_SLACK_API_PORT: "0", + FAKE_SLACK_API_WEBSOCKET_PORT: "0", + FAKE_SLACK_API_PORT_FILE: portFile, + FAKE_SLACK_API_CAPTURE_FILE: captureFile, + FAKE_SLACK_API_EXPECTED_BOT_TOKEN: "xoxb-fake-slack-port-test", + FAKE_SLACK_API_EXPECTED_APP_TOKEN: "xapp-fake-slack-port-test", + }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + + try { + await waitFor(() => fs.existsSync(portFile), `fake Slack listeners did not start: ${stderr}`); + const listening = fs + .readFileSync(captureFile, "utf8") + .trim() + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((entry) => entry.event === "listening"); + expect(listening).toHaveLength(2); + expect(listening.map((entry) => entry.kind).sort()).toEqual(["rest", "websocket"]); + expect(new Set(listening.map((entry) => entry.port)).size).toBe(2); + } finally { + child.kill("SIGTERM"); + await new Promise((resolve) => + child.exitCode !== null ? resolve() : child.once("exit", () => resolve()), + ); + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 10_000); + it("keeps raw process-probe tokens out of argv and fails closed", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-process-token-probe-")); const token = `xoxb-nemoclaw-process-probe-secret-${process.pid}`; diff --git a/test/e2e/support/openclaw-discord-pairing-helpers.test.ts b/test/e2e/support/openclaw-discord-pairing-helpers.test.ts index 6550cebc123..cf2e878ee8f 100644 --- a/test/e2e/support/openclaw-discord-pairing-helpers.test.ts +++ b/test/e2e/support/openclaw-discord-pairing-helpers.test.ts @@ -13,6 +13,13 @@ import { assertDiscordGatewayCapture, DISCORD_GATEWAY_CLIENT_SOURCE, } from "../live/messaging-providers-helpers.ts"; +import { + closeServer, + createRejectedSlackForwardProxy, + createSlackSocketClient, + createSuccessfulSlackForwardProxy, + listenOnLoopback, +} from "./fixtures/slack-forward-proxy.ts"; import { buildPairingApproveCommand, buildPairingPendingCommand, @@ -141,6 +148,47 @@ function localDiscordGatewayClientSource(): string { } describe("OpenClaw Discord pairing helper contracts", () => { + it("sends an absolute-form fake Slack WebSocket upgrade through the proxy", async () => { + const targetPort = 4443; + const envelope = { payload: { event: { type: "message" } } }; + const proxy = createSuccessfulSlackForwardProxy(envelope); + const proxyPort = await listenOnLoopback(proxy.server); + + try { + await expect(createSlackSocketClient(proxyPort, targetPort)()).resolves.toEqual(envelope); + await vi.waitFor(() => expect(proxy.websocketMessages()).not.toHaveLength(0), { + interval: 10, + timeout: 1_000, + }); + expect(proxy.requests).toHaveLength(1); + expect(proxy.requests[0]).toMatch( + new RegExp( + `^GET http://host\\.openshell\\.internal:${targetPort}/socket-mode HTTP/1\\.1`, + "u", + ), + ); + expect(JSON.parse(proxy.websocketMessages()[0] ?? "{}")).toEqual({ + type: "socket_mode_client_hello", + token: "openshell:resolve:env:v42_SLACK_APP_TOKEN", + }); + } finally { + await closeServer(proxy.server); + } + }); + + it("rejects a non-101 fake Slack WebSocket proxy response", async () => { + const proxy = createRejectedSlackForwardProxy(); + const proxyPort = await listenOnLoopback(proxy); + + try { + await expect(createSlackSocketClient(proxyPort, 4443)()).rejects.toThrow( + "fake Slack websocket upgrade failed: HTTP/1.1 502 Bad Gateway", + ); + } finally { + await closeServer(proxy); + } + }); + it("shell-quotes pairing code and user without command substitution", () => { const code = "abc$(touch /tmp/e2e-should-not-run)"; const user = "user`touch /tmp/e2e-should-not-run`"; @@ -266,7 +314,7 @@ describe("OpenClaw Discord pairing helper contracts", () => { }, ])("fails closed on invalid Slack probe input before network access: $name", ({ env, error }) => { const result = spawnSync(process.execPath, ["--input-type=module"], { - input: `${SLACK_PROBE_INPUT_VALIDATION_SOURCE}\nlet networkAttempted = false;\ntry { parseFakeSlackPort(); parseProxyTarget(); networkAttempted = true; } catch (error) { console.error(error.message); console.error("NETWORK_ATTEMPTED=" + networkAttempted); process.exit(1); }\n`, + input: `${SLACK_PROBE_INPUT_VALIDATION_SOURCE}\nlet networkAttempted = false;\ntry { parseFakeSlackPort("FAKE_SLACK_API_PORT"); parseProxyTarget(); networkAttempted = true; } catch (error) { console.error(error.message); console.error("NETWORK_ATTEMPTED=" + networkAttempted); process.exit(1); }\n`, encoding: "utf8", env: { ...process.env, ...env }, }); @@ -276,6 +324,51 @@ describe("OpenClaw Discord pairing helper contracts", () => { expect(result.stderr).toEqual(expect.stringContaining("NETWORK_ATTEMPTED=false")); }); + it.each(["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN"])( + "accepts the revision-scoped OpenShell credential reference for %s", + (name) => { + const result = spawnSync(process.execPath, ["--input-type=module"], { + input: `${SLACK_PROBE_INPUT_VALIDATION_SOURCE}\nparseManagedCredentialReference(${JSON.stringify(name)}); console.log("VALID");\n`, + encoding: "utf8", + env: { ...process.env, [name]: `openshell:resolve:env:v42_${name}` }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe("VALID\n"); + expect(result.stdout).not.toContain("openshell:resolve:env:"); + }, + ); + + it.each([ + { name: "missing", value: "" }, + { name: "raw secret", value: "xapp-raw-secret" }, + { name: "identityless canonical reference", value: "openshell:resolve:env:SLACK_APP_TOKEN" }, + { + name: "identityless provider alias", + value: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + }, + { + name: "wrong credential key", + value: "openshell:resolve:env:v42_SLACK_BOT_TOKEN", + }, + ])( + "rejects an invalid Slack app credential reference before network access: $name", + ({ value }) => { + const result = spawnSync(process.execPath, ["--input-type=module"], { + input: `${SLACK_PROBE_INPUT_VALIDATION_SOURCE}\nlet networkAttempted = false; try { parseManagedCredentialReference("SLACK_APP_TOKEN"); networkAttempted = true; } catch (error) { console.error(error.message); console.error("NETWORK_ATTEMPTED=" + networkAttempted); process.exit(1); }\n`, + encoding: "utf8", + env: { ...process.env, SLACK_APP_TOKEN: value }, + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "SLACK_APP_TOKEN must be the revision-scoped OpenShell credential reference issued to the sandbox", + ); + expect(result.stderr).toContain("NETWORK_ATTEMPTED=false"); + expect(result.stderr).not.toContain(value || "xapp-raw-secret"); + }, + ); + it("keeps the shared Discord Gateway client valid for sandbox node heredoc", () => { const result = spawnSync(process.execPath, ["--input-type=module", "--check"], { input: DISCORD_GATEWAY_CLIENT_SOURCE, diff --git a/test/e2e/support/pr-self-hosted-llama-selector.test.ts b/test/e2e/support/pr-self-hosted-llama-selector.test.ts index ec201b410d1..39c1126e473 100644 --- a/test/e2e/support/pr-self-hosted-llama-selector.test.ts +++ b/test/e2e/support/pr-self-hosted-llama-selector.test.ts @@ -9,21 +9,42 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; +type WorkflowStep = { + env?: Record; + id?: string; + if?: string; + name?: string; + run?: string; + uses?: string; + with?: Record; +}; + +type WorkflowJob = { + env?: Record; + outputs?: Record; + permissions?: Record; + steps?: WorkflowStep[]; +}; + type Workflow = { - jobs: Record }>; + jobs: Record; }; const WORKFLOW_PATH = ".github/workflows/pr-self-hosted.yaml"; const CANDIDATE_SHA = "a".repeat(40); const BASE_SHA = "b".repeat(40); +function workflow(): Workflow { + return YAML.parse(readFileSync(WORKFLOW_PATH, "utf8")) as Workflow; +} + function selectGenericGpuLane( changedFiles: readonly string[], copiedSha = CANDIDATE_SHA, baseSha = BASE_SHA, ) { - const workflow = YAML.parse(readFileSync(WORKFLOW_PATH, "utf8")) as Workflow; - const script = workflow.jobs["select-llama-cpp-generic-gpu"]?.steps?.find( + const value = workflow(); + const script = value.jobs["select-llama-cpp-generic-gpu"]?.steps?.find( (step) => step.name === "Select llama.cpp generic GPU E2E from PR files", )?.run; expect(script).toEqual(expect.any(String)); @@ -104,7 +125,51 @@ describe("generic NVIDIA GPU PR selection", () => { ); }); - it("rejects a PR base that cannot identify an exact managed-image publication", () => { + it("rejects a PR whose base SHA is not a lowercase 40-character SHA", () => { expect(() => selectGenericGpuLane(["scripts/install.sh"], CANDIDATE_SHA, "main")).toThrow(); }); + + // source-shape-contract: security -- The copied PR workflow must run the publication verifier from the validated PR base before the generic GPU job receives its managed-image revision + it("binds trusted base publication to the generic NVIDIA GPU job", () => { + const value = workflow(); + const selector = value.jobs["select-llama-cpp-generic-gpu"]; + + expect(selector?.permissions).toEqual({ actions: "read", contents: "read" }); + expect(selector?.outputs).toMatchObject({ + base_sha: "${{ steps.changed.outputs.base_sha }}", + managed_image_revision: "${{ steps.publication.outputs.head_sha }}", + }); + + const checkout = selector?.steps?.find( + (step) => step.name === "Check out PR base SHA for publication verification", + ); + expect(checkout).toMatchObject({ + if: "${{ steps.changed.outputs.selected == 'true' }}", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + with: { + "fetch-depth": 0, + "persist-credentials": false, + ref: "${{ steps.changed.outputs.base_sha }}", + }, + }); + + const publication = selector?.steps?.find((step) => step.id === "publication"); + expect(publication).toMatchObject({ + env: { + EXPECTED_SHA: "${{ steps.changed.outputs.base_sha }}", + GITHUB_TOKEN: "${{ github.token }}", + REQUIRE_MANAGED_IMAGE_PUBLICATION: "1", + }, + if: "${{ steps.changed.outputs.selected == 'true' }}", + }); + expect(publication?.run).toContain("export GITHUB_REF=refs/heads/main"); + expect(publication?.run).toContain('export GITHUB_SHA="$EXPECTED_SHA"'); + expect(publication?.run).toContain( + "node --experimental-strip-types --no-warnings tools/e2e/base-image-publication.mts --wait-seconds 3000 --poll-seconds 30", + ); + + expect(value.jobs["llama-cpp-generic-gpu"]?.env?.E2E_MANAGED_IMAGE_REVISION).toBe( + "${{ needs.select-llama-cpp-generic-gpu.outputs.managed_image_revision }}", + ); + }); }); diff --git a/test/e2e/support/stock-managed-image-workflow-boundary.test.ts b/test/e2e/support/stock-managed-image-workflow-boundary.test.ts new file mode 100644 index 00000000000..aa0537423d6 --- /dev/null +++ b/test/e2e/support/stock-managed-image-workflow-boundary.test.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + type OperationsWorkflow, + validateBaseImagePublicationGate, + validateStockOnboardingPublicationBoundary, +} from "../../../tools/e2e/operations-workflow-boundary.mts"; +import { readWorkflow } from "../../helpers/e2e-workflow-contract"; + +const STOCK_JOBS = [ + "live", + "mcp-bridge", + "openshell-credential-generation-window", + "mcp-bridge-dev", + "hermes-e2e", + "hermes-gpu-startup", + "cloud-onboard", + "messaging-providers", +] as const; + +const CATALOGUE_JOBS = [ + "catalogue-standard", + "catalogue-nvidia-api", + "catalogue-nvidia-inference", + "catalogue-github-read", + "catalogue-brave-nvidia-inference", +] as const; + +function workflow(): OperationsWorkflow { + return structuredClone(readWorkflow()) as unknown as OperationsWorkflow; +} + +describe("stock onboarding managed-image publication boundary", () => { + it("passes one selected cohort receipt to every stock onboarding job", () => { + expect(validateStockOnboardingPublicationBoundary(workflow())).toEqual([]); + }); + + it.each(STOCK_JOBS)("rejects %s without the publication dependency and receipt", (jobName) => { + const value = workflow(); + value.jobs[jobName].needs = []; + delete value.jobs[jobName].env?.E2E_MANAGED_IMAGE_REVISION; + delete value.jobs[jobName].env?.E2E_MANAGED_IMAGE_COHORT_RECEIPT; + + expect(validateStockOnboardingPublicationBoundary(value)).toEqual( + expect.arrayContaining([ + expect.stringContaining(`${jobName} must depend on base-image-publication`), + expect.stringContaining( + `${jobName} must receive the selected managed-image cohort revision`, + ), + expect.stringContaining( + `${jobName} must receive the complete selected managed-image cohort receipt`, + ), + ]), + ); + }); + + it.each(CATALOGUE_JOBS)( + "rejects %s without the publication dependency and receipt inputs", + (jobName) => { + const value = workflow(); + value.jobs[jobName].needs = []; + delete value.jobs[jobName].with?.managed_image_revision; + delete value.jobs[jobName].with?.managed_image_receipt; + + expect(validateStockOnboardingPublicationBoundary(value)).toEqual( + expect.arrayContaining([ + expect.stringContaining(`${jobName} must depend on base-image-publication`), + expect.stringContaining( + `${jobName} must pass the selected managed-image cohort revision`, + ), + expect.stringContaining( + `${jobName} must pass the complete selected managed-image cohort receipt`, + ), + ]), + ); + }, + ); + + it("blocks matrix generation when any publication architecture fails", () => { + const value = workflow(); + value.jobs["generate-matrix"].needs = []; + + expect(validateBaseImagePublicationGate(value)).toContain( + "generate-matrix must wait for complete managed-image publication", + ); + }); +}); diff --git a/test/e2e/support/trusted-target-routing-workflow-boundary.test.ts b/test/e2e/support/trusted-target-routing-workflow-boundary.test.ts index 0d43054b59e..048280539b9 100644 --- a/test/e2e/support/trusted-target-routing-workflow-boundary.test.ts +++ b/test/e2e/support/trusted-target-routing-workflow-boundary.test.ts @@ -7,7 +7,10 @@ import { readWorkflow } from "../../helpers/e2e-workflow-contract"; import { requireFixture } from "./require-fixture"; type ControllerWorkflow = { - jobs: Record }>; + jobs: Record< + string, + { steps: Array<{ env?: Record; id?: string; name?: string; run?: string }> } + >; }; const EXPECTED_ERROR = "trusted controller matrix must pin typed target runner to ubuntu-latest"; @@ -90,4 +93,16 @@ describe("trusted E2E target routing boundary (#7824)", () => { expect(validateE2eWorkflow(workflow)).toContain(EXPECTED_ERROR); }); + + it("rejects an inference credential exposed to an unauthorized PR candidate", () => { + const { workflow } = fixture(); + const run = workflow.jobs.live!.steps.find((step) => step.name === "Run live E2E tests")!; + const validationError = + "live E2E step must guard NVIDIA_INFERENCE_API_KEY behind a trusted main run or an authorized NVIDIA-owned PR dispatch"; + + expect(validateE2eWorkflow(workflow)).not.toContain(validationError); + run.env!.NVIDIA_INFERENCE_API_KEY = "${{ secrets.NVIDIA_INFERENCE_API_KEY }}"; + + expect(validateE2eWorkflow(workflow)).toContain(validationError); + }); }); diff --git a/test/e2e/support/workload-source-env.test.ts b/test/e2e/support/workload-source-env.test.ts deleted file mode 100644 index f148454fa6f..00000000000 --- a/test/e2e/support/workload-source-env.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { REPO_ROOT } from "../fixtures/paths.ts"; -import { resolveLiveE2eWorkloadSourceEnv } from "../fixtures/workload-source-env.ts"; - -describe("live E2E workload source environment", () => { - it.each([ - ["openclaw", "Dockerfile"], - ["hermes", "agents/hermes/Dockerfile"], - ["langchain-deepagents-code", "agents/langchain-deepagents-code/Dockerfile"], - ])("honors the explicit legacy-Dockerfile source for %s", (agent, dockerfile) => { - expect( - resolveLiveE2eWorkloadSourceEnv({ - E2E_TARGET_ID: "full-e2e", - E2E_WORKLOAD_SOURCE: "legacy-dockerfile", - NEMOCLAW_AGENT: agent, - }), - ).toMatchObject({ - NEMOCLAW_FROM_DOCKERFILE: path.join(REPO_ROOT, dockerfile), - }); - }); - - it("leaves an unspecified source on the product's default workload path", () => { - const input = { E2E_TARGET_ID: "full-e2e", NEMOCLAW_AGENT: "openclaw" }; - expect(resolveLiveE2eWorkloadSourceEnv(input)).toEqual(input); - }); - - it.each([ - "managed-image-protected-runtime", - "podman-native-cpu", - "mxc-runtime-proof", - ])("honors the provider-neutral managed-image source for %s", (targetId) => { - expect( - resolveLiveE2eWorkloadSourceEnv({ - E2E_TARGET_ID: targetId, - E2E_WORKLOAD_SOURCE: "managed-image", - }), - ).toEqual({ - E2E_TARGET_ID: targetId, - E2E_WORKLOAD_SOURCE: "managed-image", - }); - }); -}); diff --git a/test/helpers/onboard-script-mocks.cjs b/test/helpers/onboard-script-mocks.cjs index d07db0b8183..8da382bc4dc 100644 --- a/test/helpers/onboard-script-mocks.cjs +++ b/test/helpers/onboard-script-mocks.cjs @@ -684,17 +684,6 @@ function mockDockerSandboxLifecycleReleaseFromRunner() { runner.run = wrappedRun; } -function mockManagedImageFallback() { - const catalog = require( - path.resolve(__dirname, "../../src/lib/onboard/managed-image/catalog.ts"), - ); - catalog.resolveManagedImageCatalogFromGhcr = async () => { - throw new catalog.ManagedImageCatalogUnavailableError( - "integration fixture intentionally exercises the trusted Dockerfile fallback", - ); - }; -} - function mockFreshOpenClawPluginDiscovery() { const pluginRestore = require( path.resolve(__dirname, "../../src/lib/state/openclaw-plugin-restore.ts"), @@ -903,7 +892,6 @@ function mockManagedImageBootstrap() { }; } -process.env.NEMOCLAW_TEST_MANAGED_IMAGE_FALLBACK === "1" && mockManagedImageFallback(); if (process.env.NEMOCLAW_TEST_MANAGED_IMAGE_CATALOG === "1") { mockManagedImageCatalog(); mockManagedImageBootstrap(); diff --git a/test/onboarding/onboard-managed-image-buildless-e2e.test.ts b/test/onboarding/onboard-managed-image-buildless-e2e.test.ts index 0821b15f247..c5111ac32a8 100644 --- a/test/onboarding/onboard-managed-image-buildless-e2e.test.ts +++ b/test/onboarding/onboard-managed-image-buildless-e2e.test.ts @@ -10,6 +10,16 @@ import { describe, expect } from "vitest"; import { test } from "../e2e/fixtures/workflow-e2e-test.ts"; import { runManagedImageBuildlessE2e } from "../helpers/managed-image-buildless-e2e"; +function expectManagedOnlyGuide(relativePath: string): void { + const guide = readFileSync(path.join(import.meta.dirname, "../..", "docs", relativePath), "utf8"); + expect(guide).toContain( + "stock onboarding stops before sandbox creation and does not build a shipped Dockerfile", + ); + expect(guide).toContain("--from "); + expect(guide).not.toContain("builds the shipped repository Dockerfile instead"); + expect(guide).not.toContain("ordinary `prefer-managed` path"); +} + describe("managed image buildless onboarding orchestration contract", () => { test("renders every shipped agent's immutable launch without entering Dockerfile orchestration (#7744)", { timeout: 240_000, @@ -27,11 +37,16 @@ describe("managed image buildless onboarding orchestration contract", () => { "utf8", ); expect(commands).toContain( - "If registry or catalog availability prevents resolution, the ordinary `prefer-managed` path builds the shipped, reviewed repository Dockerfile instead; it never selects an unpinned `:latest` image.", + "If registry or catalog availability prevents resolution, stock onboarding stops before sandbox creation and does not build a shipped Dockerfile.", ); expect(commands).toContain( - "Available catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent fails closed before sandbox creation.", + "Catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent also fails closed before sandbox creation.", ); + expectManagedOnlyGuide("deployment/sandbox-hardening.mdx"); + expectManagedOnlyGuide("reference/architecture.mdx"); + expectManagedOnlyGuide("get-started/quickstart.mdx"); + expectManagedOnlyGuide("get-started/quickstart-hermes.mdx"); + expectManagedOnlyGuide("get-started/quickstart-langchain-deepagents-code.mdx"); progress.phase("validate mocked all-agent buildless orchestration boundaries"); runManagedImageBuildlessE2e(); diff --git a/test/onboarding/onboard-mcp-observability-redirect.test.ts b/test/onboarding/onboard-mcp-observability-redirect.test.ts index a06f9f4ce95..a29c06354ac 100644 --- a/test/onboarding/onboard-mcp-observability-redirect.test.ts +++ b/test/onboarding/onboard-mcp-observability-redirect.test.ts @@ -70,7 +70,8 @@ registry.getSandbox = () => fixtureMocks.managedSandboxPolicyReceiptFixture({ registry.getDefault = () => null; const { createSandbox } = require(${onboardPath}); createSandbox( - null, "model", "provider", "openai-completions", "alpha", null, null, null, + null, "model", "provider", "openai-completions", "alpha", null, null, + ${JSON.stringify(path.join(repoRoot, "agents", "langchain-deepagents-code", "Dockerfile"))}, { name: "langchain-deepagents-code", policyAdditionsPath: ${dcodePolicyPath} }, null, null, null, [], null, null, { @@ -92,7 +93,6 @@ createSandbox( HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, NEMOCLAW_NON_INTERACTIVE: "1", - NEMOCLAW_TEST_MANAGED_IMAGE_FALLBACK: "1", NEMOCLAW_RECREATE_WITHOUT_BACKUP: "1", }, }); diff --git a/test/onboarding/onboard-prepared-build-context.test.ts b/test/onboarding/onboard-prepared-build-context.test.ts index 79276d03ab1..4b63a1d18b8 100644 --- a/test/onboarding/onboard-prepared-build-context.test.ts +++ b/test/onboarding/onboard-prepared-build-context.test.ts @@ -275,7 +275,6 @@ const { createSandbox } = require(${onboardPath}); HOME: tmpDir, NEMOCLAW_HOME: path.join(tmpDir, ".nemoclaw"), NEMOCLAW_NON_INTERACTIVE: "1", - NEMOCLAW_TEST_MANAGED_IMAGE_FALLBACK: "1", PATH: `${fakeBin}:${process.env.PATH ?? ""}`, }, }); diff --git a/test/onboarding/onboard-sandbox-build.test.ts b/test/onboarding/onboard-sandbox-build.test.ts index 6ddbe1bdb0c..8d2e3405ae4 100644 --- a/test/onboarding/onboard-sandbox-build.test.ts +++ b/test/onboarding/onboard-sandbox-build.test.ts @@ -344,6 +344,7 @@ const { createSandbox } = require(${onboardPath}); const agent = { name: "hermes", displayName: "Hermes Agent", + dockerfilePath: ${JSON.stringify(path.join(repoRoot, "agents", "hermes", "Dockerfile"))}, forwardPort: 18789, forward_ports: [18789, 8642], healthProbe: { url: "http://127.0.0.1:8642/health", port: 8642, timeout_seconds: 90 }, diff --git a/test/onboarding/onboard.test.ts b/test/onboarding/onboard.test.ts index 9ec4a8a9e68..384782507ab 100644 --- a/test/onboarding/onboard.test.ts +++ b/test/onboarding/onboard.test.ts @@ -1121,7 +1121,6 @@ const { createSandboxWithTemporaryManagedRuntime } = require(${onboardPath}); NEMOCLAW_RECREATE_SANDBOX: "1", }; delete env.NEMOCLAW_RECREATE_WITHOUT_BACKUP; - delete env.NEMOCLAW_TEST_MANAGED_IMAGE_FALLBACK; const result = spawnSync(process.execPath, [scriptPath], { cwd: repoRoot, encoding: "utf-8", diff --git a/test/repository/source-require-loader.test.ts b/test/repository/source-require-loader.test.ts index 668a621dd54..b2ae52b24ed 100644 --- a/test/repository/source-require-loader.test.ts +++ b/test/repository/source-require-loader.test.ts @@ -433,8 +433,8 @@ fs.linkSync = function replaceSourceRequireLock(existingPath, claimPath) { const Module = require("node:module"); const path = require("node:path"); const expected = new Set([ - path.resolve(${JSON.stringify(path.join(import.meta.dirname, "..", "helpers", "register-source-require.ts"))}), - path.resolve(${JSON.stringify(path.join(import.meta.dirname, "..", "helpers", "source-require-cache.ts"))}), + path.resolve(${JSON.stringify(path.join(import.meta.dirname, "../helpers", "register-source-require.ts"))}), + path.resolve(${JSON.stringify(path.join(import.meta.dirname, "../helpers", "source-require-cache.ts"))}), ]); const compiled = []; const originalCompile = Module.prototype._compile; diff --git a/test/runtime/gateway/gateway-watchdog-validation.test.ts b/test/runtime/gateway/gateway-watchdog-validation.test.ts index c4cce2d4595..4f8b026541a 100644 --- a/test/runtime/gateway/gateway-watchdog-validation.test.ts +++ b/test/runtime/gateway/gateway-watchdog-validation.test.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const HERE = path.dirname(fileURLToPath(import.meta.url)); -const START_SCRIPT = path.resolve(HERE, "..", "..", "..", "scripts", "nemoclaw-start.sh"); +const START_SCRIPT = path.resolve(HERE, "../../..", "scripts", "nemoclaw-start.sh"); function requireNonNegative(value: number, message: string): number { return value >= 0 diff --git a/tools/e2e/base-image-publication.mts b/tools/e2e/base-image-publication.mts index 2c5bff93fda..8d0f6935969 100644 --- a/tools/e2e/base-image-publication.mts +++ b/tools/e2e/base-image-publication.mts @@ -11,7 +11,10 @@ const REPOSITORY = "NVIDIA/NemoClaw"; const MAIN_BRANCH = "main"; const WORKFLOW_PATH = ".github/workflows/base-image.yaml"; const WORKFLOW_FILE = "base-image.yaml"; -const WORKFLOW_NAME = "Images / Publish Base and Managed Images"; +const WORKFLOW_NAMES = new Set([ + "Images / Publish Base and Managed Images", + "Images / Base Images", +]); const API_ROOT = "https://api.github.com"; const RUN_URL_ROOT = `https://github.com/${REPOSITORY}/actions/runs`; const WORKFLOW_URL = `https://github.com/${REPOSITORY}/blob/${MAIN_BRANCH}/${WORKFLOW_PATH}`; @@ -75,6 +78,14 @@ export const REQUIRED_PUBLISHER_JOBS = [ "Build and push Hermes base image", "Build and push Deep Agents Code base image", ] as const; +const PUBLISHER_JOB_ALIASES = new Map([ + ["Build and push OpenClaw base image", "Build and push OpenClaw base image"], + ["Manifests / OpenClaw", "Build and push OpenClaw base image"], + ["Build and push Hermes base image", "Build and push Hermes base image"], + ["Manifests / Hermes", "Build and push Hermes base image"], + ["Build and push Deep Agents Code base image", "Build and push Deep Agents Code base image"], + ["Manifests / Deep Agents Code", "Build and push Deep Agents Code base image"], +]); type JsonRecord = Record; @@ -103,6 +114,7 @@ export interface PublicationWaitOptions { history: FirstParentHistory; request: (path: string) => Promise; requireWorkflowSuccess?: boolean; + selectNearestSuccessfulRun?: boolean; waitMs: number; pollMs: number; now?: () => number; @@ -150,6 +162,13 @@ function exactString(value: unknown, expected: string, label: string): string { return expected; } +function trustedWorkflowName(value: unknown, label: string): string { + if (typeof value !== "string" || !WORKFLOW_NAMES.has(value)) { + throw new Error(`${label} must be one of ${[...WORKFLOW_NAMES].join(", ")}`); + } + return value; +} + function sha(value: unknown, label: string): string { if (typeof value !== "string" || !SHA_PATTERN.test(value)) { throw new Error(`${label} must be a lowercase 40-character SHA`); @@ -284,12 +303,13 @@ export function resolveFirstParentHistory( expectedSha: string, paths: readonly string[], runGit: (args: string[]) => string = defaultGit, + options: { readonly requireCheckedOutCommit?: boolean } = {}, ): FirstParentHistory { sha(expectedSha, "expected SHA"); if (paths.length === 0) throw new Error("at least one base-image path is required"); const checkedOutSha = runGit(["rev-parse", "--verify", "HEAD^{commit}"]); - if (checkedOutSha !== expectedSha) { + if (options.requireCheckedOutCommit !== false && checkedOutSha !== expectedSha) { throw new Error( `checked-out commit ${checkedOutSha || "missing"} does not match ${expectedSha}`, ); @@ -342,7 +362,7 @@ export function resolveFirstParentHistory( export function validateWorkflow(payload: unknown): number { const workflow = asRecord(payload); const workflowId = positiveSafeInteger(workflow.id, "base-image workflow id"); - exactString(workflow.name, WORKFLOW_NAME, "base-image workflow name"); + trustedWorkflowName(workflow.name, "base-image workflow name"); exactString(workflow.path, WORKFLOW_PATH, "base-image workflow path"); exactString(workflow.state, "active", "base-image workflow state"); exactString(workflow.html_url, WORKFLOW_URL, "base-image workflow URL"); @@ -367,7 +387,7 @@ function validateRun(value: unknown, index: number, expectedWorkflowId: number): exactString(run.event, "push", `workflow run ${index} event`); exactString(run.head_branch, MAIN_BRANCH, `workflow run ${index} branch`); exactString(run.path, WORKFLOW_PATH, `workflow run ${index} path`); - exactString(run.name, WORKFLOW_NAME, `workflow run ${index} name`); + trustedWorkflowName(run.name, `workflow run ${index} name`); exactString(asRecord(run.repository).full_name, REPOSITORY, `workflow run ${index} repository`); exactString( asRecord(run.head_repository).full_name, @@ -405,6 +425,7 @@ export function selectPublicationRun( payload: unknown, history: FirstParentHistory, workflowId: number, + options: { readonly completedSuccessOnly?: boolean } = {}, ): PublicationSelection { positiveSafeInteger(workflowId, "base-image workflow id"); const response = asRecord(payload); @@ -429,10 +450,13 @@ export function selectPublicationRun( const distance = history.distanceBySha.get(run.headSha); return distance === undefined ? [] : [{ run, distance }]; }); - if (eligible.length === 0) return { state: "missing" }; + const selectable = options.completedSuccessOnly + ? eligible.filter(({ run }) => run.status === "completed" && run.conclusion === "success") + : eligible; + if (selectable.length === 0) return { state: "missing" }; - const nearestDistance = Math.min(...eligible.map(({ distance }) => distance)); - const nearest = eligible.filter(({ distance }) => distance === nearestDistance); + const nearestDistance = Math.min(...selectable.map(({ distance }) => distance)); + const nearest = selectable.filter(({ distance }) => distance === nearestDistance); if (nearest.length !== 1) { throw new Error( `multiple trusted base-image workflow runs match ${nearest[0]?.run.headSha ?? history.relevantSha}`, @@ -463,7 +487,7 @@ export function validatePublisherJobs(payload: unknown, run: PublicationRun): "p if (typeof job.name !== "string" || job.name.length === 0) { throw new Error(`publisher job ${index} name is invalid`); } - const requiredName = REQUIRED_PUBLISHER_JOBS.find((name) => name === job.name); + const requiredName = PUBLISHER_JOB_ALIASES.get(job.name); if (!requiredName) continue; if (typeof job.status !== "string") { throw new Error(`publisher job ${requiredName} status is invalid; ${run.url}`); @@ -631,7 +655,9 @@ export async function waitForBaseImagePublication( const runsPath = `/repos/${REPOSITORY}/actions/workflows/${WORKFLOW_FILE}/runs?branch=${MAIN_BRANCH}&event=push&per_page=100`; while (true) { const runs = await collectPaginated(options.request, runsPath, "workflow_runs"); - const selection = selectPublicationRun(runs, options.history, workflowId); + const selection = selectPublicationRun(runs, options.history, workflowId, { + completedSuccessOnly: options.selectNearestSuccessfulRun === true, + }); if (selection.state === "selected") { const jobsPath = `/repos/${REPOSITORY}/actions/runs/${selection.run.id}/attempts/${selection.run.attempt}/jobs?per_page=100`; if (now() > deadline) { @@ -803,6 +829,8 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro const expectedSha = env.EXPECTED_SHA ?? ""; const outputPath = env.GITHUB_OUTPUT ?? ""; const requireManagedImagePublication = env.REQUIRE_MANAGED_IMAGE_PUBLICATION ?? "0"; + const selectNearestSuccessfulRun = env.SELECT_NEAREST_SUCCESSFUL_PUBLICATION ?? "0"; + const allowNonHeadHistory = env.PUBLICATION_HISTORY_ALLOW_NON_HEAD ?? "0"; const workspace = env.GITHUB_WORKSPACE ?? process.cwd(); if (token.length === 0 || token.includes("\r") || token.includes("\n")) { throw new Error("GITHUB_TOKEN must be a non-empty single-line value"); @@ -823,14 +851,26 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro if (requireManagedImagePublication !== "0" && requireManagedImagePublication !== "1") { throw new Error("REQUIRE_MANAGED_IMAGE_PUBLICATION must be 0 or 1"); } + if (selectNearestSuccessfulRun !== "0" && selectNearestSuccessfulRun !== "1") { + throw new Error("SELECT_NEAREST_SUCCESSFUL_PUBLICATION must be 0 or 1"); + } + if (allowNonHeadHistory !== "0" && allowNonHeadHistory !== "1") { + throw new Error("PUBLICATION_HISTORY_ALLOW_NON_HEAD must be 0 or 1"); + } - const workflowSource = readFileSync(resolve(workspace, WORKFLOW_PATH), "utf8"); + const workflowSource = + allowNonHeadHistory === "1" + ? defaultGit(["show", `${expectedSha}:${WORKFLOW_PATH}`]) + : readFileSync(resolve(workspace, WORKFLOW_PATH), "utf8"); const paths = parseBaseImagePushPaths(workflowSource); - const history = resolveFirstParentHistory(expectedSha, paths); + const history = resolveFirstParentHistory(expectedSha, paths, defaultGit, { + requireCheckedOutCommit: allowNonHeadHistory !== "1", + }); const run = await waitForBaseImagePublication({ history, request: (path) => githubRequest(path, token), requireWorkflowSuccess: requireManagedImagePublication === "1", + selectNearestSuccessfulRun: selectNearestSuccessfulRun === "1", waitMs: waitSeconds * 1000, pollMs: pollSeconds * 1000, }); diff --git a/tools/e2e/cli-artifact-workflow-boundary.mts b/tools/e2e/cli-artifact-workflow-boundary.mts index a14b94b91d8..0aa638d7891 100644 --- a/tools/e2e/cli-artifact-workflow-boundary.mts +++ b/tools/e2e/cli-artifact-workflow-boundary.mts @@ -267,9 +267,6 @@ function validateProducer(errors: string[], producer: WorkflowRecord): void { ".sourceRevision == $candidateSha", "candidate CLI build identity does not match the candidate commit SHA", - ".source.revision == $revision", - ".source.release == $release", - "managed-image catalog source identity does not match the candidate", "--sort=name", "--mtime=@0", "nemoclaw/dist/shared", @@ -359,8 +356,18 @@ function validateConsumer( } let expectedNeeds: string | string[] = CLI_ARTIFACT_PRODUCER_JOB; if (jobName === "mcp-bridge-dev") { - expectedNeeds = [CLI_ARTIFACT_PRODUCER_JOB, "openshell-dev-artifact"]; - } else if (jobName === "live") { + expectedNeeds = ["base-image-publication", CLI_ARTIFACT_PRODUCER_JOB, "openshell-dev-artifact"]; + } else if ( + [ + "live", + "mcp-bridge", + "openshell-credential-generation-window", + "hermes-e2e", + "hermes-gpu-startup", + "cloud-onboard", + "messaging-providers", + ].includes(jobName) + ) { expectedNeeds = ["base-image-publication", CLI_ARTIFACT_PRODUCER_JOB]; } else if (jobName === "cloud-onboard") { expectedNeeds = ["base-image-publication", CLI_ARTIFACT_PRODUCER_JOB]; diff --git a/tools/e2e/exact-artifact-download.mts b/tools/e2e/exact-artifact-download.mts index 45a7a5967af..2ff068c58e6 100644 --- a/tools/e2e/exact-artifact-download.mts +++ b/tools/e2e/exact-artifact-download.mts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -15,6 +15,7 @@ import { githubRequest } from "./base-image-publication.mts"; const REPOSITORY = "NVIDIA/NemoClaw"; const API_ROOT = "https://api.github.com"; const CONTRACT_FILE = "contract.json"; +const COHORT_FILE = "cohort.json"; const MAX_ARCHIVE_BYTES = 1024 * 1024; const MAX_CONTRACT_BYTES = 64 * 1024; const MAX_ATTEMPTS = 3; @@ -71,6 +72,11 @@ export function exactArtifactName(expected: ExactArtifactExpectation): string { return `managed-base-${expected.runId}-${expected.runAttempt}-langchain-deepagents-code`; } +/** Derive the immutable all-agent cohort artifact name for one publication attempt. */ +export function exactManagedImageCohortArtifactName(expected: ExactArtifactExpectation): string { + return `managed-image-cohort-${expected.runId}-${expected.runAttempt}`; +} + /** Bind one named artifact and validate every immutable producer attribute. */ export function bindNamedExactArtifact( value: unknown, @@ -274,23 +280,32 @@ export async function downloadBoundArtifact( } /** Extract the sole bounded contract file from a validated artifact ZIP. */ -export function materializeContractArchive(archive: Buffer, outputDirectory: string): string { +export function materializeExactJsonArchive( + archive: Buffer, + outputDirectory: string, + fileName: typeof CONTRACT_FILE | typeof COHORT_FILE, +): string { const entries = listValidatedArtifactZipEntries(archive, { maxEntries: 2 }); - if (JSON.stringify(entries) !== JSON.stringify([CONTRACT_FILE])) { - throw new Error("artifact archive must contain exactly one contract.json regular file"); + if (JSON.stringify(entries) !== JSON.stringify([fileName])) { + throw new Error(`artifact archive must contain exactly one ${fileName} regular file`); } - const contract = readValidatedArtifactZipEntryBytes(archive, CONTRACT_FILE, { + const contract = readValidatedArtifactZipEntryBytes(archive, fileName, { maxBytes: MAX_CONTRACT_BYTES, maxEntries: 2, }); if (!contract) throw new Error("artifact contract archive is malformed"); const resolvedDirectory = path.resolve(outputDirectory); mkdirSync(resolvedDirectory, { mode: 0o700, recursive: true }); - const contractPath = path.join(resolvedDirectory, CONTRACT_FILE); + const contractPath = path.join(resolvedDirectory, fileName); writeFileSync(contractPath, contract, { mode: 0o600 }); return contractPath; } +/** Extract the sole bounded contract file from a validated artifact ZIP. */ +export function materializeContractArchive(archive: Buffer, outputDirectory: string): string { + return materializeExactJsonArchive(archive, outputDirectory, CONTRACT_FILE); +} + /** Read one required positive integer environment value. */ function requiredInteger(value: string | undefined, label: string): number { if (!value || !/^[1-9][0-9]*$/u.test(value)) throw new Error(`${label} is required`); @@ -307,14 +322,33 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro runAttempt: requiredInteger(env.PUBLICATION_RUN_ATTEMPT, "PUBLICATION_RUN_ATTEMPT"), runId: requiredInteger(env.PUBLICATION_RUN_ID, "PUBLICATION_RUN_ID"), }; - const name = exactArtifactName(expected); + const kind = env.PUBLICATION_ARTIFACT_KIND ?? "dcode-base"; + if (kind !== "dcode-base" && kind !== "managed-image-cohort") { + throw new Error("PUBLICATION_ARTIFACT_KIND must be dcode-base or managed-image-cohort"); + } + const name = + kind === "managed-image-cohort" + ? exactManagedImageCohortArtifactName(expected) + : exactArtifactName(expected); + const fileName = kind === "managed-image-cohort" ? COHORT_FILE : CONTRACT_FILE; const response = await githubRequest( `/repos/${REPOSITORY}/actions/runs/${expected.runId}/artifacts?name=${encodeURIComponent(name)}&per_page=100`, token, ); - const identity = bindExactArtifact(response, expected); + const identity = bindNamedExactArtifact(response, expected, name); const archive = await downloadBoundArtifact(identity, token); - materializeContractArchive(archive, argv[0]); + materializeExactJsonArchive(archive, argv[0], fileName); + if (env.GITHUB_OUTPUT) { + const provenance = JSON.stringify({ + artifactDigest: identity.digest, + artifactId: identity.id, + artifactName: identity.name, + revision: identity.headSha, + runAttempt: identity.runAttempt, + runId: identity.runId, + }); + appendFileSync(env.GITHUB_OUTPUT, `provenance=${provenance}\n`, "utf8"); + } } if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { diff --git a/tools/e2e/hermes-gpu-startup-workflow-boundary.mts b/tools/e2e/hermes-gpu-startup-workflow-boundary.mts index cbd2e8d54de..f7767b77148 100644 --- a/tools/e2e/hermes-gpu-startup-workflow-boundary.mts +++ b/tools/e2e/hermes-gpu-startup-workflow-boundary.mts @@ -122,7 +122,11 @@ export function validateHermesGpuStartupWorkflow( if (job["runs-on"] !== "linux-amd64-gpu-rtxpro6000-latest-1") { errors.push(`${JOB_NAME} job must run on the native RTX PRO 6000 GPU runner`); } - if (job.needs !== "generate-matrix" || job.if !== EXPECTED_SELECTOR) { + if ( + JSON.stringify(job.needs) !== + JSON.stringify(["base-image-publication", "generate-matrix"]) || + job.if !== EXPECTED_SELECTOR + ) { errors.push(`${JOB_NAME} job must use the trusted execution plan behind generate-matrix`); } if (job["timeout-minutes"] !== 90) { diff --git a/tools/e2e/managed-image-cohort-contract.mts b/tools/e2e/managed-image-cohort-contract.mts new file mode 100644 index 00000000000..16da770a46a --- /dev/null +++ b/tools/e2e/managed-image-cohort-contract.mts @@ -0,0 +1,330 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { appendFileSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + MANAGED_IMAGE_REPOSITORIES, + SHIPPED_MANAGED_IMAGE_AGENTS, + type ManagedImagePlatform, + type ShippedManagedImageAgent, +} from "../../src/lib/onboard/managed-image/contract.ts"; + +const REPOSITORY = "NVIDIA/NemoClaw"; +const PLATFORMS = ["linux/amd64", "linux/arm64"] as const; +const SHA_PATTERN = /^[0-9a-f]{40}$/u; +const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u; + +type JsonRecord = Record; + +export interface ManagedImageCohortIdentity { + readonly cohort: string; + readonly receipt: ManagedImageCohortReceipt; + readonly revision: string; + readonly runAttempt: number; + readonly runId: number; +} + +export interface ManagedImageCohortReceipt { + readonly kind: "nemoclaw-managed-image-cohort-receipt-v1"; + readonly cohort: string; + readonly revision: string; + readonly runAttempt: number; + readonly runId: number; + readonly images: Readonly< + Record>> + >; +} + +function record(value: unknown, label: string): JsonRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be a JSON object`); + } + return value as JsonRecord; +} + +function positiveInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 1) { + throw new Error(`${label} must be a positive integer`); + } + return Number(value); +} + +function exactString(value: unknown, expected: string, label: string): void { + if (value !== expected) throw new Error(`${label} must be ${expected}`); +} + +function digest(value: unknown, label: string): string { + if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) { + throw new Error(`${label} must be an immutable SHA-256 digest`); + } + return value; +} + +function exactKeys(value: JsonRecord, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const sortedExpected = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(sortedExpected)) { + throw new Error(`${label} must contain the complete expected set`); + } +} + +function validatePlatformEvidence( + value: unknown, + expected: { + readonly agent: string; + readonly cohort: string; + readonly platform: (typeof PLATFORMS)[number]; + readonly revision: string; + readonly runAttempt: number; + readonly runId: number; + }, +): void { + const platform = record(value, `${expected.agent} ${expected.platform} publication`); + const platformDigest = digest(platform.digest, `${expected.agent} ${expected.platform} digest`); + const image = + MANAGED_IMAGE_REPOSITORIES[expected.agent as keyof typeof MANAGED_IMAGE_REPOSITORIES]; + exactString( + platform.reference, + `${image}@${platformDigest}`, + `${expected.agent} ${expected.platform} reference`, + ); + const baseReference = platform.baseReference; + if (typeof baseReference !== "string" || !/@sha256:[0-9a-f]{64}$/u.test(baseReference)) { + throw new Error(`${expected.agent} ${expected.platform} base reference must be immutable`); + } + + const publicationEvidence = record( + platform.publicationEvidence, + `${expected.agent} ${expected.platform} publication evidence`, + ); + const candidateDescriptor = record( + publicationEvidence.candidateDescriptor, + `${expected.agent} ${expected.platform} candidate descriptor`, + ); + exactString( + candidateDescriptor.digest, + platformDigest, + `${expected.agent} ${expected.platform} candidate digest`, + ); + const workloadDescriptor = record( + publicationEvidence.workloadDescriptor, + `${expected.agent} ${expected.platform} workload descriptor`, + ); + const workloadDigest = digest( + workloadDescriptor.digest, + `${expected.agent} ${expected.platform} workload digest`, + ); + const workloadPlatform = record( + workloadDescriptor.platform, + `${expected.agent} ${expected.platform} workload platform`, + ); + const [os, architecture] = expected.platform.split("/"); + exactString(workloadPlatform.os, os, `${expected.agent} ${expected.platform} operating system`); + exactString( + workloadPlatform.architecture, + architecture, + `${expected.agent} ${expected.platform} architecture`, + ); + + const attestations = record( + publicationEvidence.attestations, + `${expected.agent} ${expected.platform} attestations`, + ); + const manifestDescriptor = record( + attestations.manifestDescriptor, + `${expected.agent} ${expected.platform} attestation manifest descriptor`, + ); + const manifestAnnotations = record( + manifestDescriptor.annotations, + `${expected.agent} ${expected.platform} attestation manifest annotations`, + ); + exactString( + manifestAnnotations["vnd.docker.reference.digest"], + workloadDigest, + `${expected.agent} ${expected.platform} attestation manifest workload digest`, + ); + const slsa = record(attestations.slsa, `${expected.agent} ${expected.platform} SLSA evidence`); + const statement = record(slsa.statement, `${expected.agent} ${expected.platform} SLSA statement`); + const slsaSubject = record( + statement.subject, + `${expected.agent} ${expected.platform} SLSA subject`, + ); + exactString( + slsaSubject.digest, + workloadDigest, + `${expected.agent} ${expected.platform} SLSA subject workload digest`, + ); + exactString( + statement.builderId, + `https://github.com/${REPOSITORY}/actions/runs/${expected.runId}/attempts/${expected.runAttempt}`, + `${expected.agent} ${expected.platform} builder`, + ); + const bindings = record( + statement.bindings, + `${expected.agent} ${expected.platform} SLSA bindings`, + ); + exactString(bindings.agent, expected.agent, `${expected.agent} ${expected.platform} agent`); + exactString(bindings.cohort, expected.cohort, `${expected.agent} ${expected.platform} cohort`); + exactString( + bindings.platform, + expected.platform, + `${expected.agent} ${expected.platform} binding`, + ); + exactString( + bindings.revision, + expected.revision, + `${expected.agent} ${expected.platform} revision`, + ); + exactString(bindings.source, `https://github.com/${REPOSITORY}`, `${expected.agent} source`); + exactString( + bindings.baseReference, + baseReference, + `${expected.agent} ${expected.platform} base reference binding`, + ); + const spdx = record(attestations.spdx, `${expected.agent} ${expected.platform} SPDX evidence`); + const spdxStatement = record( + spdx.statement, + `${expected.agent} ${expected.platform} SPDX statement`, + ); + const spdxSubject = record( + spdxStatement.subject, + `${expected.agent} ${expected.platform} SPDX subject`, + ); + exactString( + spdxSubject.digest, + workloadDigest, + `${expected.agent} ${expected.platform} SPDX subject workload digest`, + ); +} + +/** Validate one complete published cohort against its selected workflow attempt. */ +export function validateManagedImageCohort( + value: unknown, + expected: { readonly revision: string; readonly runAttempt: number; readonly runId: number }, +): ManagedImageCohortIdentity { + if (!SHA_PATTERN.test(expected.revision)) throw new Error("expected cohort revision is invalid"); + positiveInteger(expected.runId, "expected cohort run id"); + positiveInteger(expected.runAttempt, "expected cohort run attempt"); + + const cohort = record(value, "managed-image cohort"); + if (cohort.contractVersion !== 2) + throw new Error("managed-image cohort contract version must be 2"); + const expectedCohort = `ghrun-${expected.runId}-${expected.runAttempt}`; + exactString(cohort.cohort, expectedCohort, "managed-image cohort identity"); + const source = record(cohort.source, "managed-image cohort source"); + exactString(source.repository, REPOSITORY, "managed-image cohort source repository"); + exactString(source.revision, expected.revision, "managed-image cohort source revision"); + const run = record(cohort.run, "managed-image cohort run"); + if (run.id !== expected.runId || run.attempt !== expected.runAttempt) { + throw new Error("managed-image cohort run does not match the selected publication"); + } + if (JSON.stringify(cohort.platforms) !== JSON.stringify(PLATFORMS)) { + throw new Error("managed-image cohort must contain linux/amd64 and linux/arm64"); + } + + const agents = record(cohort.agents, "managed-image cohort agents"); + exactKeys(agents, SHIPPED_MANAGED_IMAGE_AGENTS, "managed-image cohort agents"); + for (const agent of SHIPPED_MANAGED_IMAGE_AGENTS) { + const contract = record(agents[agent], `${agent} cohort contract`); + const image = MANAGED_IMAGE_REPOSITORIES[agent]; + const manifestDigest = digest(contract.digest, `${agent} cohort digest`); + exactString(contract.image, image, `${agent} cohort image`); + exactString(contract.reference, `${image}@${manifestDigest}`, `${agent} cohort reference`); + exactString(contract.alias, `${image}:cohort-${expectedCohort}`, `${agent} cohort alias`); + exactString( + record(contract.descriptor, `${agent} cohort descriptor`).digest, + manifestDigest, + `${agent} cohort descriptor digest`, + ); + const platforms = record(contract.platforms, `${agent} cohort platforms`); + exactKeys(platforms, PLATFORMS, `${agent} cohort platforms`); + for (const platform of PLATFORMS) { + validatePlatformEvidence(platforms[platform], { + agent, + cohort: expectedCohort, + platform, + revision: expected.revision, + runAttempt: expected.runAttempt, + runId: expected.runId, + }); + } + } + + const images = Object.fromEntries( + SHIPPED_MANAGED_IMAGE_AGENTS.map((agent) => { + const platforms = record( + record(agents[agent], `${agent} cohort contract`).platforms, + `${agent} cohort platforms`, + ); + return [ + agent, + Object.fromEntries( + PLATFORMS.map((platform) => { + const publication = record(platforms[platform], `${agent} ${platform} publication`); + const workloadDescriptor = record( + record(publication.publicationEvidence, `${agent} ${platform} publication evidence`) + .workloadDescriptor, + `${agent} ${platform} workload descriptor`, + ); + const workloadDigest = digest( + workloadDescriptor.digest, + `${agent} ${platform} workload digest`, + ); + return [platform, `${MANAGED_IMAGE_REPOSITORIES[agent]}@${workloadDigest}`]; + }), + ), + ]; + }), + ) as ManagedImageCohortReceipt["images"]; + const receipt: ManagedImageCohortReceipt = { + kind: "nemoclaw-managed-image-cohort-receipt-v1", + cohort: expectedCohort, + revision: expected.revision, + runAttempt: expected.runAttempt, + runId: expected.runId, + images, + }; + + return { + cohort: expectedCohort, + receipt, + revision: expected.revision, + runAttempt: expected.runAttempt, + runId: expected.runId, + }; +} + +function requiredInteger(value: string | undefined, label: string): number { + if (!value || !/^[1-9][0-9]*$/u.test(value)) throw new Error(`${label} is required`); + return positiveInteger(Number(value), label); +} + +export function main(argv = process.argv.slice(2), env = process.env): void { + if (argv.length !== 1) throw new Error("expected one managed-image cohort contract path"); + const identity = validateManagedImageCohort( + JSON.parse(readFileSync(argv[0], "utf8")) as unknown, + { + revision: env.PUBLICATION_HEAD_SHA ?? "", + runAttempt: requiredInteger(env.PUBLICATION_RUN_ATTEMPT, "PUBLICATION_RUN_ATTEMPT"), + runId: requiredInteger(env.PUBLICATION_RUN_ID, "PUBLICATION_RUN_ID"), + }, + ); + if (!env.GITHUB_OUTPUT) throw new Error("GITHUB_OUTPUT is required"); + appendFileSync( + env.GITHUB_OUTPUT, + `cohort=${identity.cohort}\nreceipt=${JSON.stringify(identity.receipt)}\nrevision=${identity.revision}\nrun_attempt=${identity.runAttempt}\nrun_id=${identity.runId}\n`, + "utf8", + ); +} + +if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : "unknown managed-image cohort error"); + process.exitCode = 1; + } +} diff --git a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts index e7031afd020..25fe4abd7b2 100644 --- a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts +++ b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts @@ -122,7 +122,6 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR E2E_ARTIFACT_DIR: "${{ github.workspace }}/e2e-artifacts/live/managed-image-protected-runtime", E2E_JOB: "1", E2E_TARGET_ID: JOB_ID, - E2E_WORKLOAD_SOURCE: "managed-image", RELEASE_E2E_ACTIVATION_PATH: ACTIVATION_PATH, NEMOCLAW_E2E_EXPECTED_SHA: "${{ inputs.checkout_sha }}", NEMOCLAW_E2E_SHARD: "linux-amd64-gpu", diff --git a/tools/e2e/mcp-dev-workflow-boundary-digests.mts b/tools/e2e/mcp-dev-workflow-boundary-digests.mts index bdaa805a6ee..7a619f60de2 100644 --- a/tools/e2e/mcp-dev-workflow-boundary-digests.mts +++ b/tools/e2e/mcp-dev-workflow-boundary-digests.mts @@ -6,7 +6,7 @@ import { createHash } from "node:crypto"; export const MCP_DEV_WORKFLOW_EXECUTION_CONTEXT_SHA256 = "052c49d5e8688266dbf38fa911733132d33e4470a29a61deb6e7a11067737559"; export const MCP_DEV_JOB_EXECUTION_CONTEXT_SHA256 = - "b9219b0f29da3834499a7c9dcb0acc8287cb3441d01a51bed615d7b2ea4383f9"; + "6b37ff9bfe69b299c76d517c0f165dd22d25fe2520c80bc9edc2af4fe4f65c43"; export const MCP_DEV_TRUSTED_NODE_SETUP_CONTENT_SHA256 = "504821ad93c57971d0281ef1130ed6008fadd331bd56acb1a6b5e6a3358f3e49"; export const MCP_DEV_TRUSTED_PREFIX_CONTENT_SHA256 = diff --git a/tools/e2e/mcp-workflow-boundary.mts b/tools/e2e/mcp-workflow-boundary.mts index a51f6eeca15..15ab193c700 100644 --- a/tools/e2e/mcp-workflow-boundary.mts +++ b/tools/e2e/mcp-workflow-boundary.mts @@ -167,6 +167,18 @@ function validateJobIdentity( jobName, `${jobName} must use its job id as E2E_TARGET_ID`, ); + requireEqual( + errors, + env.E2E_MANAGED_IMAGE_REVISION, + "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }}", + `${jobName} must receive the selected managed-image cohort revision`, + ); + requireEqual( + errors, + env.E2E_MANAGED_IMAGE_COHORT_RECEIPT, + "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }}", + `${jobName} must receive the complete selected managed-image cohort receipt`, + ); requireEqual( errors, job["timeout-minutes"], @@ -178,7 +190,9 @@ function validateJobIdentity( errors, JSON.stringify(jobNeeds(job)), JSON.stringify( - jobName === "mcp-bridge-dev" ? ["generate-matrix", DEV_ARTIFACT_JOB] : ["generate-matrix"], + jobName === "mcp-bridge-dev" + ? ["base-image-publication", "generate-matrix", DEV_ARTIFACT_JOB] + : ["base-image-publication", "generate-matrix"], ), `${jobName} must depend on its reviewed artifact producers`, ); @@ -841,8 +855,8 @@ function validateCredentialWindowJob( requireEqual( errors, JSON.stringify(jobNeeds(job)), - JSON.stringify(["generate-matrix"]), - `${CREDENTIAL_WINDOW_JOB} must depend only on matrix generation so it can run in parallel`, + JSON.stringify(["base-image-publication", "generate-matrix"]), + `${CREDENTIAL_WINDOW_JOB} must depend on publication and matrix generation`, ); requireEqual( errors, @@ -865,6 +879,10 @@ function validateCredentialWindowJob( const env = asRecord(job.env); const expectedEnv = { + E2E_MANAGED_IMAGE_REVISION: + "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }}", + E2E_MANAGED_IMAGE_COHORT_RECEIPT: + "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }}", E2E_JOB: "1", E2E_TARGET_ID: CREDENTIAL_WINDOW_JOB, E2E_AGENT_RUNTIME: "openclaw", diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index bba18f5fc0a..9aa781868aa 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -33,31 +33,32 @@ const LIVE_VITEST_HELPER = "tools/e2e/live-vitest-invocation.mts run --test-path const E2E_ARTIFACT_ACTION = "NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@"; const COLD_ONBOARD_PERFORMANCE_EVIDENCE_PATH = "e2e-artifacts/live/${{ matrix.id }}/onboard-progress-budget.json"; -const PUBLICATION_REQUIRED_CONDITION = "${{ steps.publication_mode.outputs.required == '1' }}"; -const PUBLICATION_REUSE_CONDITION = "${{ steps.publication_mode.outputs.reuse == '1' }}"; -const PR_DCODE_BASE_PAIRING_CONDITION = - "${{ steps.publication_mode.outputs.reuse == '1' && needs.generate-matrix.outputs.managed_image_catalog != '' }}"; -const PUBLICATION_REQUIRED_OR_REUSE_CONDITION = - "${{ steps.publication_mode.outputs.required == '1' || steps.publication_mode.outputs.reuse == '1' }}"; const PUBLICATION_CLASSIFIER_SCRIPT = [ "set -euo pipefail", - "reuse=0", 'case "${REPOSITORY}:${REF}:${EVENT_NAME}:${CHECKOUT_SHA:+controller}" in', " NVIDIA/NemoClaw:refs/heads/main:push:|NVIDIA/NemoClaw:refs/heads/main:workflow_dispatch:)", - " required=1", + ' expected_sha="$WORKFLOW_SHA"', + " allow_non_head=0", + " select_nearest_successful=0", " ;;", " NVIDIA/NemoClaw:refs/heads/*:workflow_dispatch:controller)", - " required=0", - " reuse=1", + ' [[ "$BASE_SHA" =~ ^[a-f0-9]{40}$ ]] || {', + ' echo "::error::manual PR publication selection requires an exact base SHA" >&2', + " exit 1", + " }", + ' expected_sha="$BASE_SHA"', + " allow_non_head=1", + " select_nearest_successful=1", " ;;", " *)", ' echo "::error::base-image publication mode is not trusted" >&2', " exit 1", " ;;", "esac", - 'printf \'required=%s\\n\' "${required}" >> "${GITHUB_OUTPUT}"', - 'printf \'reuse=%s\\n\' "${reuse}" >> "${GITHUB_OUTPUT}"', + 'printf \'allow_non_head=%s\\n\' "${allow_non_head}" >> "${GITHUB_OUTPUT}"', + 'printf \'expected_sha=%s\\n\' "${expected_sha}" >> "${GITHUB_OUTPUT}"', + 'printf \'select_nearest_successful=%s\\n\' "${select_nearest_successful}" >> "${GITHUB_OUTPUT}"', ].join("\n") + "\n"; const ISSUE_API_REFERENCE = /\bgithub\.rest\.issues\b/u; const ISSUE_MUTATION_BEYOND_COMMENT = @@ -492,8 +493,7 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow const trustedPublicationCheckout = jobName === "base-image-publication" && step.name === "Check out trusted E2E workflow" && - step.if === PUBLICATION_REQUIRED_OR_REUSE_CONDITION && - step.with?.ref === "${{ inputs.workflow_sha || github.workflow_sha }}"; + step.with?.ref === "${{ github.workflow_sha }}"; const trustedManagedImageRuntimeCheckout = jobName === "managed-image-protected-runtime" && step.name === "Checkout trusted protected runtime qualification" && @@ -598,16 +598,19 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): const errors: string[] = []; const job = workflow.jobs["base-image-publication"] ?? {}; const expectedJob = { - needs: "generate-matrix", "runs-on": "ubuntu-latest", "timeout-minutes": 55, outputs: { - dcode_base_contract: - "${{ steps.validate_dcode_base.outputs.contract || steps.validate_reused_dcode_base.outputs.contract }}", - dcode_base_ref: - "${{ steps.validate_dcode_base.outputs.base_ref || steps.validate_reused_dcode_base.outputs.base_ref }}", + dcode_base_contract: "${{ steps.validate_dcode_base.outputs.contract }}", + dcode_base_ref: "${{ steps.validate_dcode_base.outputs.base_ref }}", + managed_image_artifact_provenance: + "${{ steps.download_managed_cohort.outputs.provenance }}", + managed_image_cohort: "${{ steps.validate_managed_cohort.outputs.cohort }}", + managed_image_receipt: "${{ steps.validate_managed_cohort.outputs.receipt }}", managed_image_revision: - "${{ steps.publication.outputs.head_sha || (steps.publication_mode.outputs.reuse == '1' && 'e38db201413b457614904187377ed9fd002d281d') || inputs.checkout_sha || github.sha }}", + "${{ steps.validate_managed_cohort.outputs.revision }}", + managed_image_run_attempt: "${{ steps.validate_managed_cohort.outputs.run_attempt }}", + managed_image_run_id: "${{ steps.validate_managed_cohort.outputs.run_id }}", }, permissions: { actions: "read", @@ -618,65 +621,59 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): id: "publication_mode", name: "Classify base-image publication requirement", env: { + BASE_SHA: "${{ inputs.base_sha }}", CHECKOUT_SHA: "${{ inputs.checkout_sha }}", EVENT_NAME: "${{ github.event_name }}", REF: "${{ github.ref }}", REPOSITORY: "${{ github.repository }}", + WORKFLOW_SHA: "${{ github.workflow_sha }}", }, shell: "bash", run: PUBLICATION_CLASSIFIER_SCRIPT, }, { name: "Check out trusted E2E workflow", - if: PUBLICATION_REQUIRED_OR_REUSE_CONDITION, uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", with: { - ref: "${{ inputs.workflow_sha || github.workflow_sha }}", + ref: "${{ github.workflow_sha }}", "fetch-depth": 0, "persist-credentials": false, }, }, { name: "Set up Node for publication verification", - if: PUBLICATION_REQUIRED_OR_REUSE_CONDITION, uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", with: { "node-version": 22, }, }, - { - id: "pr_dcode_base", - name: "Resolve Deep Agents Code base publication for the exact PR managed image", - if: PR_DCODE_BASE_PAIRING_CONDITION, - env: { - CANDIDATE_REPOSITORY: "${{ inputs.checkout_repository }}", - CANDIDATE_SHA: "${{ inputs.checkout_sha }}", - GITHUB_TOKEN: "${{ github.token }}", - MANAGED_IMAGE_CATALOG: "${{ needs.generate-matrix.outputs.managed_image_catalog }}", - }, - run: "node --experimental-strip-types --no-warnings tools/e2e/pr-dcode-base-publication.mts", - }, { id: "publication", - name: "Verify applicable base-image publication", - if: PUBLICATION_REQUIRED_CONDITION, + name: "Select complete base and managed-image publication", env: { - EXPECTED_SHA: "${{ inputs.checkout_sha || github.sha }}", + EXPECTED_SHA: "${{ steps.publication_mode.outputs.expected_sha }}", GITHUB_TOKEN: "${{ github.token }}", + PUBLICATION_HISTORY_ALLOW_NON_HEAD: + "${{ steps.publication_mode.outputs.allow_non_head }}", REQUIRE_MANAGED_IMAGE_PUBLICATION: "1", + SELECT_NEAREST_SUCCESSFUL_PUBLICATION: + "${{ steps.publication_mode.outputs.select_nearest_successful }}", }, shell: "bash", run: [ "set -euo pipefail", "export GITHUB_REF=refs/heads/main", 'export GITHUB_SHA="$EXPECTED_SHA"', - "node --experimental-strip-types --no-warnings tools/e2e/base-image-publication.mts --wait-seconds 3000 --poll-seconds 30", + "wait_seconds=3000", + 'if [[ "$SELECT_NEAREST_SUCCESSFUL_PUBLICATION" == "1" ]]; then', + " wait_seconds=300", + "fi", + 'node --experimental-strip-types --no-warnings tools/e2e/base-image-publication.mts --wait-seconds "$wait_seconds" --poll-seconds 30', "", ].join("\n"), }, { name: "Download immutable Deep Agents Code base contract", - if: PUBLICATION_REQUIRED_CONDITION, env: { GITHUB_TOKEN: "${{ github.token }}", PUBLICATION_HEAD_SHA: "${{ steps.publication.outputs.head_sha }}", @@ -686,40 +683,36 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): run: 'node --experimental-strip-types --no-warnings tools/e2e/exact-artifact-download.mts "${RUNNER_TEMP}/dcode-base-contract"', }, { - name: "Download reused Deep Agents Code base contract", - if: PUBLICATION_REUSE_CONDITION, + id: "validate_dcode_base", + name: "Validate immutable Deep Agents Code base", env: { - GITHUB_TOKEN: "${{ github.token }}", - PUBLICATION_HEAD_SHA: - "${{ steps.pr_dcode_base.outputs.head_sha || 'e38db201413b457614904187377ed9fd002d281d' }}", - PUBLICATION_RUN_ATTEMPT: "${{ steps.pr_dcode_base.outputs.run_attempt || '1' }}", - PUBLICATION_RUN_ID: "${{ steps.pr_dcode_base.outputs.run_id || '32544159037' }}", + PUBLICATION_HEAD_SHA: "${{ steps.publication.outputs.head_sha }}", + PUBLICATION_RUN_ATTEMPT: "${{ steps.publication.outputs.run_attempt }}", + PUBLICATION_RUN_ID: "${{ steps.publication.outputs.run_id }}", }, - run: 'node --experimental-strip-types --no-warnings tools/e2e/exact-artifact-download.mts "${RUNNER_TEMP}/dcode-base-contract-reused"', + run: 'node --experimental-strip-types --no-warnings tools/e2e/dcode-base-image-contract.mts "${RUNNER_TEMP}/dcode-base-contract/contract.json"', }, { - id: "validate_dcode_base", - name: "Validate immutable Deep Agents Code base", - if: PUBLICATION_REQUIRED_CONDITION, + id: "download_managed_cohort", + name: "Download immutable managed-image cohort contract", env: { + GITHUB_TOKEN: "${{ github.token }}", + PUBLICATION_ARTIFACT_KIND: "managed-image-cohort", PUBLICATION_HEAD_SHA: "${{ steps.publication.outputs.head_sha }}", PUBLICATION_RUN_ATTEMPT: "${{ steps.publication.outputs.run_attempt }}", PUBLICATION_RUN_ID: "${{ steps.publication.outputs.run_id }}", }, - run: 'node --experimental-strip-types --no-warnings tools/e2e/dcode-base-image-contract.mts "${RUNNER_TEMP}/dcode-base-contract/contract.json"', + run: 'node --experimental-strip-types --no-warnings tools/e2e/exact-artifact-download.mts "${RUNNER_TEMP}/managed-image-cohort"', }, { - id: "validate_reused_dcode_base", - name: "Validate reused Deep Agents Code base", - if: PUBLICATION_REUSE_CONDITION, + id: "validate_managed_cohort", + name: "Validate immutable managed-image cohort contract", env: { - EXPECTED_BASE_REF: "${{ steps.pr_dcode_base.outputs.base_ref }}", - PUBLICATION_HEAD_SHA: - "${{ steps.pr_dcode_base.outputs.head_sha || 'e38db201413b457614904187377ed9fd002d281d' }}", - PUBLICATION_RUN_ATTEMPT: "${{ steps.pr_dcode_base.outputs.run_attempt || '1' }}", - PUBLICATION_RUN_ID: "${{ steps.pr_dcode_base.outputs.run_id || '32544159037' }}", + PUBLICATION_HEAD_SHA: "${{ steps.publication.outputs.head_sha }}", + PUBLICATION_RUN_ATTEMPT: "${{ steps.publication.outputs.run_attempt }}", + PUBLICATION_RUN_ID: "${{ steps.publication.outputs.run_id }}", }, - run: 'node --experimental-strip-types --no-warnings tools/e2e/dcode-base-image-contract.mts "${RUNNER_TEMP}/dcode-base-contract-reused/contract.json"', + run: 'node --experimental-strip-types --no-warnings tools/e2e/managed-image-cohort-contract.mts "${RUNNER_TEMP}/managed-image-cohort/cohort.json"', }, ], }; @@ -730,8 +723,8 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): ); } const matrix = workflow.jobs["generate-matrix"] ?? {}; - if (needs(matrix).includes("base-image-publication")) { - errors.push("generate-matrix must not wait for base-image-publication"); + if (!sameMembers(needs(matrix), ["base-image-publication"])) { + errors.push("generate-matrix must wait for complete managed-image publication"); } const matrixOutputs = matrix.outputs ?? {}; if ("dcode_base_contract" in matrixOutputs || "dcode_base_ref" in matrixOutputs) { @@ -755,7 +748,7 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): } if ( live.env?.E2E_MANAGED_IMAGE_REVISION !== - "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }}" + "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }}" ) { errors.push( "live stock onboarding must use the selected managed-image revision when no exact PR catalog is present", @@ -780,10 +773,16 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): `${jobName} must use the selected managed-image revision when no exact PR catalog is present`, ); } + if ( + catalogue.with?.managed_image_catalog !== + "${{ needs.generate-matrix.outputs.managed_image_catalog }}" + ) { + errors.push(`${jobName} must pass the selected exact PR managed-image catalog`); + } } if ( live.env?.NEMOCLAW_LANGCHAIN_DEEPAGENTS_CODE_SANDBOX_BASE_IMAGE_REF !== - "${{ needs.base-image-publication.outputs.dcode_base_ref }}" + "${{ needs.base-image-publication.outputs.dcode_base_ref }}" ) { errors.push("live DCode must use the selected immutable base reference"); } @@ -820,6 +819,62 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): return errors; } +const STOCK_ONBOARDING_JOBS = [ + "live", + "mcp-bridge", + "openshell-credential-generation-window", + "mcp-bridge-dev", + "hermes-e2e", + "hermes-gpu-startup", + "cloud-onboard", + "messaging-providers", +] as const; + +const STOCK_ONBOARDING_CATALOGUE_JOBS = [ + "catalogue-standard", + "catalogue-nvidia-api", + "catalogue-nvidia-inference", + "catalogue-github-read", + "catalogue-brave-nvidia-inference", +] as const; + +const MANAGED_IMAGE_REVISION_EXPRESSION = + "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }}"; +const MANAGED_IMAGE_RECEIPT_EXPRESSION = + "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }}"; + +/** Require publication success and one exact cohort revision for every stock onboarding job. */ +export function validateStockOnboardingPublicationBoundary( + workflow: OperationsWorkflow, +): string[] { + const errors: string[] = []; + for (const jobName of STOCK_ONBOARDING_JOBS) { + const job = workflow.jobs[jobName] ?? {}; + if (!needs(job).includes("base-image-publication")) { + errors.push(`${jobName} must depend on base-image-publication before stock onboarding`); + } + if (job.env?.E2E_MANAGED_IMAGE_REVISION !== MANAGED_IMAGE_REVISION_EXPRESSION) { + errors.push(`${jobName} must receive the selected managed-image cohort revision`); + } + if (job.env?.E2E_MANAGED_IMAGE_COHORT_RECEIPT !== MANAGED_IMAGE_RECEIPT_EXPRESSION) { + errors.push(`${jobName} must receive the complete selected managed-image cohort receipt`); + } + } + for (const jobName of STOCK_ONBOARDING_CATALOGUE_JOBS) { + const job = workflow.jobs[jobName] ?? {}; + if (!needs(job).includes("base-image-publication")) { + errors.push(`${jobName} must depend on base-image-publication before stock onboarding`); + } + if (job.with?.managed_image_revision !== MANAGED_IMAGE_REVISION_EXPRESSION) { + errors.push(`${jobName} must pass the selected managed-image cohort revision`); + } + if (job.with?.managed_image_receipt !== MANAGED_IMAGE_RECEIPT_EXPRESSION) { + errors.push(`${jobName} must pass the complete selected managed-image cohort receipt`); + } + } + return errors; +} + function validatePrGateEvidenceProducers(errors: string[], workflow: OperationsWorkflow): void { const requiredJobs = new Set(RISK_RULES.flatMap((rule) => rule.requiredJobs)); for (const jobId of requiredJobs) { @@ -1354,6 +1409,7 @@ export function validateE2eOperationsWorkflow( workflow as unknown as Record, ); errors.push(...validateBaseImagePublicationGate(workflow)); + errors.push(...validateStockOnboardingPublicationBoundary(workflow)); validateManualPrDispatch(errors, workflow); validatePrGateEvidenceProducers(errors, workflow); validateAggregation(errors, workflow); diff --git a/tools/e2e/standard-profile-workflow-boundary.mts b/tools/e2e/standard-profile-workflow-boundary.mts index fb4c11bc9f9..8958e291b4b 100644 --- a/tools/e2e/standard-profile-workflow-boundary.mts +++ b/tools/e2e/standard-profile-workflow-boundary.mts @@ -45,7 +45,6 @@ const SKILL_AGENT_UPLOAD_PATH = `${[ const PROFILE_JOBS = { standard: { job: "catalogue-standard", - displayName: "${{ matrix.display_name }}", matrix: "catalogue_standard_matrix", credentialBoundary: "no provider credential", secrets: ["DOCKERHUB_TOKEN", "DOCKERHUB_USERNAME"], @@ -54,7 +53,6 @@ const PROFILE_JOBS = { }, "nvidia-api": { job: "catalogue-nvidia-api", - displayName: "${{ matrix.display_name }}", matrix: "catalogue_nvidia_api_matrix", credentialBoundary: "NVIDIA API key", secrets: ["DOCKERHUB_TOKEN", "DOCKERHUB_USERNAME", "NVIDIA_API_KEY"], @@ -63,7 +61,6 @@ const PROFILE_JOBS = { }, "nvidia-inference": { job: "catalogue-nvidia-inference", - displayName: "${{ matrix.display_name }}", matrix: "catalogue_nvidia_inference_matrix", credentialBoundary: "NVIDIA inference API key", secrets: ["DOCKERHUB_TOKEN", "DOCKERHUB_USERNAME", "NVIDIA_INFERENCE_API_KEY"], @@ -72,7 +69,6 @@ const PROFILE_JOBS = { }, "github-read": { job: "catalogue-github-read", - displayName: "${{ matrix.display_name }}", matrix: "catalogue_github_read_matrix", credentialBoundary: "GitHub read token", secrets: ["DOCKERHUB_TOKEN", "DOCKERHUB_USERNAME"], @@ -81,10 +77,14 @@ const PROFILE_JOBS = { }, "brave-nvidia-inference": { job: "catalogue-brave-nvidia-inference", - displayName: "${{ matrix.display_name }}", matrix: "catalogue_brave_nvidia_inference_matrix", credentialBoundary: "Brave and NVIDIA inference API keys", - secrets: ["BRAVE_API_KEY", "DOCKERHUB_TOKEN", "DOCKERHUB_USERNAME", "NVIDIA_INFERENCE_API_KEY"], + secrets: [ + "BRAVE_API_KEY", + "DOCKERHUB_TOKEN", + "DOCKERHUB_USERNAME", + "NVIDIA_INFERENCE_API_KEY", + ], githubToken: false, maxParallel: 2, }, @@ -137,7 +137,7 @@ function validateProfileCallers(errors: string[], workflow: WorkflowRecord): voi `${contract.job} must call the standard E2E profile after matrix generation and base-image publication`, ); } - if (job.name !== contract.displayName) { + if (job.name !== "${{ matrix.display_name }}") { errors.push(`${contract.job} must use the planned outcome-first display name`); } const matrixOutput = `needs.generate-matrix.outputs.${contract.matrix}`; @@ -166,6 +166,8 @@ function validateProfileCallers(errors: string[], workflow: WorkflowRecord): voi managed_image_catalog: "${{ needs.generate-matrix.outputs.managed_image_catalog }}", managed_image_revision: "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }}", + managed_image_receipt: + "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_receipt || '' }}", credential_boundary: contract.credentialBoundary, catalogue_id: "${{ matrix.id }}", target_id: "${{ matrix.target_id }}", @@ -215,6 +217,7 @@ function validateProfileWorkflow(errors: string[], profile: WorkflowRecord): voi cli_artifact_provenance: "string", managed_image_catalog: "string", managed_image_revision: "string", + managed_image_receipt: "string", credential_boundary: "string", catalogue_id: "string", target_id: "string", @@ -284,6 +287,7 @@ function validateProfileWorkflow(errors: string[], profile: WorkflowRecord): voi E2E_JOB: "1", E2E_MANAGED_IMAGE_REVISION: "${{ inputs.managed_image_revision }}", E2E_TARGET_ID: "${{ inputs.target_id }}", + E2E_MANAGED_IMAGE_COHORT_RECEIPT: "${{ inputs.managed_image_receipt }}", NEMOCLAW_RUN_LIVE_E2E: "1", NEMOCLAW_E2E_EXPECTED_SHA: "${{ inputs.candidate_sha }}", NEMOCLAW_E2E_CORRELATION_ID: "${{ inputs.risk_signal_correlation_id }}", @@ -496,10 +500,11 @@ function validateProfileWorkflow(errors: string[], profile: WorkflowRecord): voi cloudflared.shell !== EXECUTION_PLAN_SHELL || !isDeepStrictEqual(record(cloudflared.env), { CLOUDFLARED_VERSION: "2026.6.1", - CLOUDFLARED_DEB_SHA256: "ccd02ec216c62bfa573395d8f72cb2e91e95cbdf8726a8acc06b3e2d9aa31526", + CLOUDFLARED_DEB_SHA256: + "ccd02ec216c62bfa573395d8f72cb2e91e95cbdf8726a8acc06b3e2d9aa31526", }) || !cloudflaredRun.includes( - "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64.deb", + 'https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64.deb', ) || !cloudflaredRun.includes("sha256sum -c -") || !cloudflaredRun.includes('dpkg-deb -f "${cloudflared_deb}" Package') || @@ -602,7 +607,8 @@ function validateProfileWorkflow(errors: string[], profile: WorkflowRecord): voi "${{ inputs.trusted_main && secrets.NVIDIA_INFERENCE_API_KEY || '' }}" || executeEnv.COMPATIBLE_API_KEY !== "${{ inputs.compatible_api_key && inputs.trusted_main && secrets.NVIDIA_INFERENCE_API_KEY || '' }}" || - executeEnv.BRAVE_API_KEY !== "${{ inputs.trusted_main && secrets.BRAVE_API_KEY || '' }}" || + executeEnv.BRAVE_API_KEY !== + "${{ inputs.trusted_main && secrets.BRAVE_API_KEY || '' }}" || executeEnv.GITHUB_TOKEN !== "${{ inputs.github_token && inputs.trusted_main && github.token || '' }}" ) { diff --git a/tools/e2e/trusted-hermes-swap-workflow-boundary.mts b/tools/e2e/trusted-hermes-swap-workflow-boundary.mts index ca841803cc5..af271502c92 100644 --- a/tools/e2e/trusted-hermes-swap-workflow-boundary.mts +++ b/tools/e2e/trusted-hermes-swap-workflow-boundary.mts @@ -258,7 +258,7 @@ export function validateTrustedHermesSwapWorkflow(workflowValue: unknown): strin continue; } - if (job.needs !== "generate-matrix") { + if (!isDeepStrictEqual(job.needs, ["base-image-publication", "generate-matrix"])) { errors.push(`${jobName} trusted Hermes swap job must depend on controller validation`); } if (provisionSteps.length !== 1) { diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index b1070f02e10..8bca7f44ca5 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -222,6 +222,7 @@ const GUARDED_DOCKER_HUB_AUTH_REQUIRED = `\${{ ${TRUSTED_DOCKER_HUB_PREDICATE} & const GUARDED_DOCKER_HUB_USERNAME = `\${{ ${TRUSTED_DOCKER_HUB_PREDICATE} && secrets.DOCKERHUB_USERNAME || '' }}`; const GUARDED_DOCKER_HUB_TOKEN = `\${{ ${TRUSTED_DOCKER_HUB_PREDICATE} && secrets.DOCKERHUB_TOKEN || '' }}`; const GUARDED_HERMES_E2E_INFERENCE_KEY = `\${{ github.repository == 'NVIDIA/NemoClaw' && github.event_name == 'workflow_dispatch' && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && (inputs.inference_mode || 'mock') != 'mock' && secrets.NVIDIA_INFERENCE_API_KEY || '' }}`; +const GUARDED_LIVE_E2E_INFERENCE_KEY = `\${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_INFERENCE_API_KEY || '' }}`; const RUNNER_ROUTING_OUTPUT = "${{ steps.runner_routing.outputs.runner_routing }}"; const RUNNER_ROUTING_STEP_NAME = "Build trusted larger-runner routing"; const RUNNER_ROUTING_SCRIPT = [ @@ -1176,8 +1177,13 @@ function validateFreeStandingJobSelector( const job = asRecord(jobs[jobName]); const expectedNeeds = jobName === "mcp-bridge-dev" - ? ["generate-matrix", "openshell-dev-artifact"] - : jobName === "cloud-onboard" + ? ["base-image-publication", "generate-matrix", "openshell-dev-artifact"] + : [ + "mcp-bridge", + "openshell-credential-generation-window", + "cloud-onboard", + "messaging-providers", + ].includes(jobName) ? ["base-image-publication", "generate-matrix"] : "generate-matrix"; if (!isDeepStrictEqual(job.needs, expectedNeeds)) { @@ -1632,8 +1638,8 @@ function validateHermesE2EJob(errors: string[], jobs: WorkflowRecord): void { return; } - if (job.needs !== "generate-matrix") { - errors.push("hermes-e2e job must depend on generate-matrix validation"); + if (!isDeepStrictEqual(job.needs, ["base-image-publication", "generate-matrix"])) { + errors.push("hermes-e2e job must depend on publication and generate-matrix validation"); } if (job.if !== "${{ needs.generate-matrix.outputs.hermes_selected == 'true' }}") { errors.push("hermes-e2e job must use validated hermes_selected output"); @@ -1745,7 +1751,7 @@ function validateJetsonControllerBoundary(errors: string[], jobs: WorkflowRecord const publication = asRecord(jobs["base-image-publication"]); if ( asRecord(publication.outputs).managed_image_revision !== - "${{ steps.publication.outputs.head_sha || (steps.publication_mode.outputs.reuse == '1' && 'e38db201413b457614904187377ed9fd002d281d') || inputs.checkout_sha || github.sha }}" + "${{ steps.validate_managed_cohort.outputs.revision }}" ) { errors.push("base-image-publication must expose the managed-image revision to Jetson dispatch"); } @@ -2577,43 +2583,6 @@ function validateTrustedE2ePlannerBoundary( } } -function validateExactPrManagedImageCatalogBoundary( - errors: string[], - generateSteps: WorkflowRecord[], - generate: WorkflowRecord | undefined, - generateCheckout: WorkflowRecord | undefined, -): void { - const managedCatalog = requireStep( - errors, - generateSteps, - "Resolve exact PR managed-image catalog", - ); - if ( - managedCatalog?.if !== - "${{ inputs.checkout_sha != '' && (inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '') }}" || - !isDeepStrictEqual(asRecord(managedCatalog?.env), { - BASE_SHA: "${{ inputs.base_sha }}", - CANDIDATE_REPOSITORY: "${{ inputs.checkout_repository }}", - CANDIDATE_SHA: "${{ inputs.checkout_sha }}", - GITHUB_TOKEN: "${{ github.token }}", - PR_NUMBER: "${{ inputs.pr_number }}", - }) || - managedCatalog?.run !== - 'node --experimental-strip-types --no-warnings tools/e2e/pr-managed-image-publication.mts "${RUNNER_TEMP}/pr-managed-image-catalog.json"' - ) { - errors.push("manual PR E2E must resolve the exact candidate managed-image publication"); - } - if ( - generate && - managedCatalog && - generateCheckout && - (generateSteps.indexOf(managedCatalog) <= generateSteps.indexOf(generate) || - generateSteps.indexOf(managedCatalog) >= generateSteps.indexOf(generateCheckout)) - ) { - errors.push("exact managed-image publication must resolve before candidate checkout"); - } -} - export function validateE2eWorkflow(workflowValue: unknown): string[] { const workflow = asRecord(workflowValue); const errors: string[] = []; @@ -2838,7 +2807,6 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { validateLargerRunnerRouting(errors, jobs, generateMatrix, generateSteps, generateCheckout); const generate = requireStep(errors, generateSteps, "Generate E2E target matrix"); validateTrustedE2ePlannerBoundary(errors, generateSteps, generate, generateCheckout); - validateExactPrManagedImageCatalogBoundary(errors, generateSteps, generate, generateCheckout); const generateEnv = asRecord(generate?.env); if (generateEnv.CHECKOUT_SHA !== "${{ inputs.checkout_sha }}") { errors.push("matrix generation step must bind controller checkout through CHECKOUT_SHA env"); @@ -2904,14 +2872,6 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { } const jobEnv = asRecord(liveTargets.env); - if ( - jobEnv.E2E_MANAGED_IMAGE_REVISION !== - "${{ needs.generate-matrix.outputs.managed_image_catalog == '' && needs.base-image-publication.outputs.managed_image_revision || '' }}" - ) { - errors.push( - "live stock onboarding must use the selected managed-image revision when no exact PR catalog is present", - ); - } if (jobEnv.NEMOCLAW_RUN_LIVE_E2E !== "1") { errors.push("live job must set NEMOCLAW_RUN_LIVE_E2E=1"); } @@ -3057,8 +3017,10 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { if (runVitestEnv.TARGET_ID !== "${{ matrix.id }}") { errors.push("live E2E step must pass matrix.id through TARGET_ID env"); } - if (runVitestEnv.NVIDIA_INFERENCE_API_KEY !== "${{ secrets.NVIDIA_INFERENCE_API_KEY }}") { - errors.push("live E2E step must receive NVIDIA_INFERENCE_API_KEY from secrets"); + if (runVitestEnv.NVIDIA_INFERENCE_API_KEY !== GUARDED_LIVE_E2E_INFERENCE_KEY) { + errors.push( + "live E2E step must guard NVIDIA_INFERENCE_API_KEY behind a trusted main run or an authorized NVIDIA-owned PR dispatch", + ); } requireRunContains(errors, runVitest, "tools/e2e/live-vitest-invocation.mts run --test-path"); requireRunContains(errors, runVitest, "test/e2e/live/registry-targets.test.ts");