diff --git a/.github/actions/ci-static-checks/action.yaml b/.github/actions/ci-static-checks/action.yaml index d72124fe311..a98eb4b9e3d 100644 --- a/.github/actions/ci-static-checks/action.yaml +++ b/.github/actions/ci-static-checks/action.yaml @@ -41,6 +41,12 @@ runs: shell: bash run: npm install --ignore-scripts + - name: Verify reviewed runtime bundles + shell: bash + run: | + npm --prefix tools/mcp-tool-discovery-runtime ci --ignore-scripts --no-audit --no-fund + npm --prefix tools/mcp-tool-discovery-runtime run bundle:reviewed:check + - name: Validate config schemas shell: bash run: npm run validate:configs diff --git a/.github/workflows/e2e-standard-profile.yaml b/.github/workflows/e2e-standard-profile.yaml index 1f4958df82b..64eb1918244 100644 --- a/.github/workflows/e2e-standard-profile.yaml +++ b/.github/workflows/e2e-standard-profile.yaml @@ -21,6 +21,9 @@ on: cli_artifact_provenance: required: true type: string + managed_image_catalog: + required: true + type: string credential_boundary: required: true type: string @@ -196,12 +199,18 @@ jobs: exit 1 } - if [[ "${REPOSITORY}" != "NVIDIA/NemoClaw" || "${REF}" != "refs/heads/main" ]]; then - fail "workflow must run from NVIDIA/NemoClaw main" + if [[ "${REPOSITORY}" != "NVIDIA/NemoClaw" ]]; then + fail "workflow must run from NVIDIA/NemoClaw" fi if [[ "${EVENT_NAME}" != "push" && "${EVENT_NAME}" != "workflow_dispatch" ]]; then fail "workflow event must be push or workflow_dispatch" fi + if [[ "${EVENT_NAME}" == "push" && "${REF}" != "refs/heads/main" ]]; then + fail "push workflow must run from NVIDIA/NemoClaw main" + fi + if [[ "${EVENT_NAME}" == "workflow_dispatch" && "${REF}" != refs/heads/* ]]; then + fail "manual workflow must run from an NVIDIA/NemoClaw branch" + fi # PR E2E mode: maintainer-dispatched PR commit. if [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${CHECKOUT_SHA}" ]]; then if [[ ! "${CHECKOUT_SHA}" =~ ^[0-9a-f]{40}$ ]]; then @@ -356,6 +365,7 @@ jobs: with: repository: ${{ inputs.candidate_repository }} ref: ${{ inputs.candidate_sha }} + fetch-depth: 0 persist-credentials: false - name: Authenticate to Docker Hub @@ -382,6 +392,45 @@ jobs: with: provenance-json: ${{ inputs.cli_artifact_provenance }} + - name: Materialize temporary managed-image catalog + if: ${{ inputs.managed_image_catalog != '' }} + shell: /bin/bash --noprofile --norc -e -o pipefail {0} + env: + CANDIDATE_SHA: ${{ inputs.candidate_sha }} + MANAGED_IMAGE_CATALOG: ${{ inputs.managed_image_catalog }} + RESTORE_CLI: ${{ inputs.restore_cli && 'true' || 'false' }} + run: | + set -euo pipefail + catalog_path="${RUNNER_TEMP}/e2e-managed-image-catalog.json" + jq -e --arg revision "$CANDIDATE_SHA" ' + type == "object" and length > 0 and + all(.[]; + .source.revision == $revision and + (.source.release | type == "string" and length > 0) and + (.source.cohort | type == "string" and length > 0) + ) and + ([.[].source.release] | unique | length) == 1 and + ([.[].source.cohort] | unique | length) == 1 + ' <<<"$MANAGED_IMAGE_CATALOG" >/dev/null || { + echo "::error::managed-image catalog source identity does not match the candidate" >&2 + exit 1 + } + if [[ "$RESTORE_CLI" == "true" ]]; then + candidate_release="v$(jq -r '.nemoclawVersion' dist/build-identity.json)" + jq -e --arg release "$candidate_release" ' + all(.[]; .source.release == $release) + ' <<<"$MANAGED_IMAGE_CATALOG" >/dev/null || { + echo "::error::managed-image catalog release does not match the restored CLI" >&2 + exit 1 + } + fi + 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 reviewed cloudflared if: ${{ inputs.cloudflared }} shell: /bin/bash --noprofile --norc -e -o pipefail {0} @@ -433,12 +482,18 @@ jobs: exit 1 } - if [[ "${REPOSITORY}" != "NVIDIA/NemoClaw" || "${REF}" != "refs/heads/main" ]]; then - fail "workflow must run from NVIDIA/NemoClaw main" + if [[ "${REPOSITORY}" != "NVIDIA/NemoClaw" ]]; then + fail "workflow must run from NVIDIA/NemoClaw" fi if [[ "${EVENT_NAME}" != "push" && "${EVENT_NAME}" != "workflow_dispatch" ]]; then fail "workflow event must be push or workflow_dispatch" fi + if [[ "${EVENT_NAME}" == "push" && "${REF}" != "refs/heads/main" ]]; then + fail "push workflow must run from NVIDIA/NemoClaw main" + fi + if [[ "${EVENT_NAME}" == "workflow_dispatch" && "${REF}" != refs/heads/* ]]; then + fail "manual workflow must run from an NVIDIA/NemoClaw branch" + fi if [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${CHECKOUT_SHA}" ]]; then if [[ ! "${CHECKOUT_SHA}" =~ ^[0-9a-f]{40}$ ]]; then fail "checkout SHA must be lowercase 40-hex" @@ -580,7 +635,7 @@ jobs: [[ "$JOB_STATUS" =~ ^(success|failure|cancelled)$ ]] || { echo "::error::E2E job status is invalid" >&2; exit 1; } [[ "$ARTIFACT_DIRECTORY" =~ ^e2e-artifacts/live/[a-z0-9]+([_-][a-z0-9]+)*(/[a-z0-9]+([_-][a-z0-9]+)*)?$ ]] || { echo "::error::E2E artifact directory is invalid" >&2; exit 1; } install -d -m 0700 "$ARTIFACT_DIRECTORY" - product_evidence_count="$(find -P "$ARTIFACT_DIRECTORY" -type f ! -name evidence-manifest.json -printf . | wc -c)" + product_evidence_count="$(find -P "$ARTIFACT_DIRECTORY" -type f ! -name evidence-manifest.json -exec printf . \; | wc -c | tr -d '[:space:]')" if [[ "$JOB_STATUS" == "success" && "$product_evidence_count" == "0" ]]; then echo "::error::successful E2E target produced no product evidence" >&2 exit 1 diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 2a420aad33f..876b4599723 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -99,8 +99,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 55 outputs: - dcode_base_contract: ${{ steps.validate_dcode_base.outputs.contract }} - dcode_base_ref: ${{ steps.validate_dcode_base.outputs.base_ref }} + 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 }} permissions: actions: read contents: read @@ -115,12 +115,14 @@ jobs: 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 ;; - NVIDIA/NemoClaw:refs/heads/main:workflow_dispatch:controller) - required=1 + NVIDIA/NemoClaw:refs/heads/*:workflow_dispatch:controller) + required=0 + reuse=1 ;; *) echo "::error::base-image publication mode is not trusted" >&2 @@ -128,17 +130,18 @@ jobs: ;; esac printf 'required=%s\n' "${required}" >> "${GITHUB_OUTPUT}" + printf 'reuse=%s\n' "${reuse}" >> "${GITHUB_OUTPUT}" - name: Check out trusted E2E workflow - if: ${{ steps.publication_mode.outputs.required == '1' }} + if: ${{ steps.publication_mode.outputs.required == '1' || steps.publication_mode.outputs.reuse == '1' }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.sha }} + ref: ${{ inputs.checkout_sha || github.sha }} fetch-depth: 0 persist-credentials: false - name: Set up Node for publication verification - if: ${{ steps.publication_mode.outputs.required == '1' }} + 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 @@ -147,9 +150,14 @@ jobs: name: Verify applicable base-image publication if: ${{ steps.publication_mode.outputs.required == '1' }} env: - EXPECTED_SHA: ${{ github.sha }} + EXPECTED_SHA: ${{ inputs.checkout_sha || github.sha }} GITHUB_TOKEN: ${{ github.token }} - run: node --experimental-strip-types --no-warnings tools/e2e/base-image-publication.mts --wait-seconds 3000 --poll-seconds 30 + 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 - name: Download immutable Deep Agents Code base contract if: ${{ steps.publication_mode.outputs.required == '1' }} @@ -160,6 +168,15 @@ 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: e38db201413b457614904187377ed9fd002d281d + PUBLICATION_RUN_ATTEMPT: "1" + PUBLICATION_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' }} @@ -169,6 +186,15 @@ jobs: 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' }} + env: + PUBLICATION_HEAD_SHA: e38db201413b457614904187377ed9fd002d281d + PUBLICATION_RUN_ATTEMPT: "1" + PUBLICATION_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" + generate-matrix: runs-on: ubuntu-latest timeout-minutes: 10 @@ -178,6 +204,7 @@ 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 }} @@ -235,8 +262,8 @@ jobs: run: | set -euo pipefail - [[ "$WORKFLOW_EVENT" == "workflow_dispatch" && "$WORKFLOW_REF" == "refs/heads/main" ]] || { - echo "::error::Manual PR E2E must be dispatched from main" >&2 + [[ "$WORKFLOW_EVENT" == "workflow_dispatch" && "$WORKFLOW_REF" == refs/heads/* ]] || { + echo "::error::Manual PR E2E must be dispatched from this repository branch" >&2 exit 1 } [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::pr_number must be a positive integer" >&2; exit 1; } @@ -500,6 +527,7 @@ jobs: BEFORE_SHA: ${{ github.event.before }} CANDIDATE_SHA: ${{ github.sha }} NEMOCLAW_E2E_CREDENTIALS_ALLOWED: ${{ (inputs.checkout_sha == '' || steps.candidate_authorization.outputs.nvidia_owned == 'true') && 'true' || 'false' }} + NEMOCLAW_E2E_BRAVE_API_KEY_AVAILABLE: ${{ secrets.BRAVE_API_KEY != '' && 'true' || 'false' }} NVIDIA_OWNED: ${{ steps.candidate_authorization.outputs.nvidia_owned }} run: | set -euo pipefail @@ -614,7 +642,7 @@ jobs: if [[ "$WORKFLOW_REPOSITORY" == "NVIDIA/NemoClaw" && "$NVIDIA_OWNED" == "true" && "$EVENT_NAME" == "workflow_dispatch" && - "$REF" == "refs/heads/main" && + "$REF" == refs/heads/* && "$CHECKOUT_SHA" =~ ^[a-f0-9]{40}$ && "$WORKFLOW_SHA" =~ ^[a-f0-9]{40}$ && "$EXPECTED_WORKFLOW_SHA" == "$WORKFLOW_SHA" && @@ -675,7 +703,14 @@ jobs: 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" @@ -2459,8 +2494,7 @@ jobs: EVIDENCE_DIRECTORY: ${{ runner.temp }}/native-runtime-aggregate QUALIFICATION_PLAN: ${{ needs.native-runtime-qualification-producer-plan.outputs.matrix }} run: >- - node --experimental-strip-types --no-warnings - tools/e2e/native-runtime-qualification-producer-aggregate.mts + node --experimental-strip-types --no-warnings tools/e2e/native-runtime-qualification-producer-aggregate.mts - name: Upload aggregate evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -2483,6 +2517,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - name: Prepare E2E workspace @@ -2680,6 +2715,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false # Keep only the credential-bearing step anchored. Cleanup mappings stay @@ -2688,9 +2724,9 @@ jobs: name: Authenticate to Docker Hub uses: NVIDIA/NemoClaw/.github/actions/docker-auth-setup@05fa6b810017752ab21148cb7e9d82d12a88c92f with: - auth-required: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && '1' || '0' }} - username: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_USERNAME || '' }} - token: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_TOKEN || '' }} + auth-required: ${{ 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') && '1' || '0' }} + username: ${{ 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.DOCKERHUB_USERNAME || '' }} + token: ${{ 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.DOCKERHUB_TOKEN || '' }} - name: Configure live E2E trace directory env: @@ -2731,6 +2767,7 @@ jobs: BASE_CONTRACT: ${{ needs.base-image-publication.outputs.dcode_base_contract }} CANDIDATE_SHA: ${{ inputs.checkout_sha || github.sha }} TARGET_ID: ${{ matrix.id }} + TARGET_LABEL: ${{ matrix.label }} shell: bash run: | set -euo pipefail @@ -2744,6 +2781,19 @@ jobs: (.candidateSha | test("^[0-9a-f]{40}$")) and (.base.reference | test("@sha256:[0-9a-f]{64}$")) )' >"${evidence_dir}/dcode-base-image.json" + test_artifact_name="$(node -e ' + const slug = process.argv[1] + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + process.stdout.write(slug || "unnamed-test"); + ' "${TARGET_LABEL}")" + test_evidence_dir="${E2E_ARTIFACT_DIR}/${test_artifact_name}" + install -d -m 0700 "${test_evidence_dir}" + install -m 0600 \ + "${evidence_dir}/dcode-base-image.json" \ + "${test_evidence_dir}/dcode-base-image.json" - name: Restore exact-commit CLI artifact uses: NVIDIA/NemoClaw/.github/actions/restore-e2e-cli-artifact@c246409193a31133cab10c8a3589001cc0d59eb3 @@ -2902,6 +2952,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - name: Prepare E2E workspace @@ -2943,6 +2994,7 @@ jobs: risk_signal_expected_sha: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && inputs.checkout_sha || '' }} risk_signal_correlation_id: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && inputs.correlation_id || '' }} cli_artifact_provenance: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} + managed_image_catalog: ${{ needs.generate-matrix.outputs.managed_image_catalog }} credential_boundary: no provider credential target_id: ${{ matrix.target_id }} catalogue_id: ${{ matrix.id }} @@ -2962,10 +3014,10 @@ jobs: github_token: false shard: ${{ matrix.shard }} artifact_layout: ${{ matrix.artifact_layout }} - trusted_main: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') }} + trusted_main: ${{ 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: - DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_USERNAME || '' }} - DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_TOKEN || '' }} + DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_USERNAME || '' }} + DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_TOKEN || '' }} catalogue-nvidia-api: name: ${{ matrix.display_name }} @@ -2982,6 +3034,7 @@ jobs: risk_signal_expected_sha: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && inputs.checkout_sha || '' }} risk_signal_correlation_id: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && inputs.correlation_id || '' }} cli_artifact_provenance: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} + managed_image_catalog: ${{ needs.generate-matrix.outputs.managed_image_catalog }} credential_boundary: NVIDIA API key target_id: ${{ matrix.target_id }} catalogue_id: ${{ matrix.id }} @@ -3001,11 +3054,11 @@ jobs: github_token: false shard: ${{ matrix.shard }} artifact_layout: ${{ matrix.artifact_layout }} - trusted_main: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') }} + trusted_main: ${{ 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: - DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_USERNAME || '' }} - DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_TOKEN || '' }} - NVIDIA_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_API_KEY || '' }} + DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_USERNAME || '' }} + DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_TOKEN || '' }} + NVIDIA_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_API_KEY || '' }} catalogue-nvidia-inference: name: ${{ matrix.display_name }} @@ -3022,6 +3075,7 @@ jobs: risk_signal_expected_sha: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && inputs.checkout_sha || '' }} risk_signal_correlation_id: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && inputs.correlation_id || '' }} cli_artifact_provenance: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} + managed_image_catalog: ${{ needs.generate-matrix.outputs.managed_image_catalog }} credential_boundary: NVIDIA inference API key target_id: ${{ matrix.target_id }} catalogue_id: ${{ matrix.id }} @@ -3041,11 +3095,11 @@ jobs: github_token: false shard: ${{ matrix.shard }} artifact_layout: ${{ matrix.artifact_layout }} - trusted_main: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') }} + trusted_main: ${{ 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: - DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_USERNAME || '' }} - DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_TOKEN || '' }} - NVIDIA_INFERENCE_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_INFERENCE_API_KEY || '' }} + DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_USERNAME || '' }} + DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_TOKEN || '' }} + NVIDIA_INFERENCE_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_INFERENCE_API_KEY || '' }} catalogue-github-read: name: ${{ matrix.display_name }} @@ -3062,6 +3116,7 @@ jobs: risk_signal_expected_sha: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && inputs.checkout_sha || '' }} risk_signal_correlation_id: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && inputs.correlation_id || '' }} cli_artifact_provenance: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} + managed_image_catalog: ${{ needs.generate-matrix.outputs.managed_image_catalog }} credential_boundary: GitHub read token target_id: ${{ matrix.target_id }} catalogue_id: ${{ matrix.id }} @@ -3081,10 +3136,10 @@ jobs: github_token: true shard: ${{ matrix.shard }} artifact_layout: ${{ matrix.artifact_layout }} - trusted_main: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') }} + trusted_main: ${{ 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: - DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_USERNAME || '' }} - DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_TOKEN || '' }} + DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_USERNAME || '' }} + DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_TOKEN || '' }} catalogue-brave-nvidia-inference: name: ${{ matrix.display_name }} @@ -3102,6 +3157,7 @@ jobs: risk_signal_expected_sha: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && inputs.checkout_sha || '' }} risk_signal_correlation_id: ${{ github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && inputs.correlation_id || '' }} cli_artifact_provenance: ${{ needs.generate-matrix.outputs.cli_artifact_provenance }} + managed_image_catalog: ${{ needs.generate-matrix.outputs.managed_image_catalog }} credential_boundary: Brave and NVIDIA inference API keys target_id: ${{ matrix.target_id }} catalogue_id: ${{ matrix.id }} @@ -3121,12 +3177,12 @@ jobs: github_token: false shard: ${{ matrix.shard }} artifact_layout: ${{ matrix.artifact_layout }} - trusted_main: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') }} + trusted_main: ${{ 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: - DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_USERNAME || '' }} - DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_TOKEN || '' }} - BRAVE_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.BRAVE_API_KEY || '' }} - NVIDIA_INFERENCE_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_INFERENCE_API_KEY || '' }} + DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_USERNAME || '' }} + DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.DOCKERHUB_TOKEN || '' }} + BRAVE_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.BRAVE_API_KEY || '' }} + NVIDIA_INFERENCE_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_INFERENCE_API_KEY || '' }} openshell-gateway-auth-contract: needs: generate-matrix @@ -3150,6 +3206,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - *dockerhub-auth @@ -3244,7 +3301,7 @@ jobs: steps: - id: trusted_hermes_swap name: Provision trusted Hermes E2E swap - if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && matrix.agent == 'hermes' }} + if: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && matrix.agent == 'hermes' }} shell: /bin/bash --noprofile --norc -e -o pipefail {0} env: BASH_ENV: /dev/null @@ -3276,12 +3333,18 @@ jobs: exit 1 } - if [[ "${REPOSITORY}" != "NVIDIA/NemoClaw" || "${REF}" != "refs/heads/main" ]]; then - fail "workflow must run from NVIDIA/NemoClaw main" + if [[ "${REPOSITORY}" != "NVIDIA/NemoClaw" ]]; then + fail "workflow must run from NVIDIA/NemoClaw" fi if [[ "${EVENT_NAME}" != "push" && "${EVENT_NAME}" != "workflow_dispatch" ]]; then fail "workflow event must be push or workflow_dispatch" fi + if [[ "${EVENT_NAME}" == "push" && "${REF}" != "refs/heads/main" ]]; then + fail "push workflow must run from NVIDIA/NemoClaw main" + fi + if [[ "${EVENT_NAME}" == "workflow_dispatch" && "${REF}" != refs/heads/* ]]; then + fail "manual workflow must run from an NVIDIA/NemoClaw branch" + fi # PR E2E mode: maintainer-dispatched PR commit. if [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${CHECKOUT_SHA}" ]]; then if [[ ! "${CHECKOUT_SHA}" =~ ^[0-9a-f]{40}$ ]]; then @@ -3436,6 +3499,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - *dockerhub-auth @@ -3529,8 +3593,7 @@ jobs: name: Scan MCP artifacts for fixture credentials if: always() run: >- - npx tsx tools/e2e/assert-mcp-artifact-secrets-absent.mts - e2e-artifacts/live/mcp-bridge/${{ matrix.agent }} + npx tsx tools/e2e/assert-mcp-artifact-secrets-absent.mts e2e-artifacts/live/mcp-bridge/${{ matrix.agent }} - name: Upload MCP server artifacts if: ${{ always() && steps.mcp_artifact_secret_scan.outcome == 'success' }} @@ -3571,6 +3634,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - *dockerhub-auth @@ -3650,8 +3714,7 @@ jobs: name: Scan credential-window artifacts for fixture credentials if: always() run: >- - npx tsx tools/e2e/assert-mcp-artifact-secrets-absent.mts - e2e-artifacts/live/openshell-credential-generation-window + npx tsx tools/e2e/assert-mcp-artifact-secrets-absent.mts e2e-artifacts/live/openshell-credential-generation-window - name: Upload credential-window artifacts if: ${{ always() && steps.credential_window_artifact_secret_scan.outcome == 'success' }} @@ -3696,9 +3759,7 @@ jobs: - id: resolve_openshell_dev_artifact name: Resolve immutable OpenShell dev artifact run: >- - node --experimental-strip-types --no-warnings - "${{ github.workspace }}/.trusted-openshell-dev-artifact/tools/e2e/openshell-dev-artifact.mts" resolve - "${{ runner.temp }}/openshell-dev-artifact" + node --experimental-strip-types --no-warnings "${{ github.workspace }}/.trusted-openshell-dev-artifact/tools/e2e/openshell-dev-artifact.mts" resolve "${{ runner.temp }}/openshell-dev-artifact" - name: Upload OpenShell dev artifact resolution if: ${{ always() }} @@ -3752,6 +3813,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - *dockerhub-auth @@ -3781,17 +3843,12 @@ jobs: OPENSHELL_DEV_EXPECTED_MANIFEST_SHA256: ${{ needs.openshell-dev-artifact.outputs.manifest_sha256 }} OPENSHELL_DEV_EXPECTED_SOURCE_COMMIT: ${{ needs.openshell-dev-artifact.outputs.source_commit }} run: >- - node --experimental-strip-types --no-warnings - "${{ github.workspace }}/.trusted-openshell-dev-artifact/tools/e2e/openshell-dev-artifact.mts" verify - "$OPENSHELL_DEV_ARTIFACT_DIR" - "$OPENSHELL_DEV_EXPECTED_SOURCE_COMMIT" - "$OPENSHELL_DEV_EXPECTED_MANIFEST_SHA256" + node --experimental-strip-types --no-warnings "${{ github.workspace }}/.trusted-openshell-dev-artifact/tools/e2e/openshell-dev-artifact.mts" verify "$OPENSHELL_DEV_ARTIFACT_DIR" "$OPENSHELL_DEV_EXPECTED_SOURCE_COMMIT" "$OPENSHELL_DEV_EXPECTED_MANIFEST_SHA256" - name: Revoke Docker auth before OpenShell development tooling shell: bash run: >- - bash - "${{ github.workspace }}/.trusted-openshell-dev-artifact/.github/scripts/docker-auth-cleanup.sh" + bash "${{ github.workspace }}/.trusted-openshell-dev-artifact/.github/scripts/docker-auth-cleanup.sh" - name: Install immutable OpenShell dev artifact env: @@ -3899,8 +3956,7 @@ jobs: name: Scan MCP artifacts for fixture credentials if: always() run: >- - npx tsx tools/e2e/assert-mcp-artifact-secrets-absent.mts - e2e-artifacts/live/mcp-bridge-dev/${{ matrix.agent }} + npx tsx tools/e2e/assert-mcp-artifact-secrets-absent.mts e2e-artifacts/live/mcp-bridge-dev/${{ matrix.agent }} - name: Upload MCP server artifacts if: ${{ always() && steps.mcp_artifact_secret_scan.outcome == 'success' }} @@ -3914,12 +3970,11 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh - # Manual PR qualification also requires the exact candidate activation contract. managed-image-multiarch-startup: name: Protected managed-image startup (${{ matrix.platform }}) - needs: generate-matrix - if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-multiarch-startup') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime')) }} + needs: [base-image-publication, generate-matrix] + if: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-multiarch-startup') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime')) }} runs-on: ${{ matrix.runner }} timeout-minutes: 210 permissions: @@ -3973,8 +4028,8 @@ jobs: shell: bash run: | set -euo pipefail - [[ "$REPOSITORY" == "NVIDIA/NemoClaw" && "$REF" == "refs/heads/main" && ( "$EVENT_NAME" == "push" || "$EVENT_NAME" == "workflow_dispatch" ) ]] || { - echo "::error::Protected managed-image startup must run from trusted NVIDIA/NemoClaw main" >&2 + [[ "$REPOSITORY" == "NVIDIA/NemoClaw" && ( ( "$EVENT_NAME" == "push" && "$REF" == "refs/heads/main" ) || ( "$EVENT_NAME" == "workflow_dispatch" && "$REF" == refs/heads/* ) ) ]] || { + echo "::error::Protected managed-image startup must run from NVIDIA/NemoClaw" >&2 exit 1 } [[ "$CHECKOUT_SHA" =~ ^[a-f0-9]{40}$ && "$BASE_SHA" =~ ^[a-f0-9]{40}$ ]] || { @@ -4091,6 +4146,7 @@ jobs: - id: bases name: Resolve exact platform base images env: + DCODE_BASE_CONTRACT: ${{ needs.base-image-publication.outputs.dcode_base_contract }} PLATFORM: ${{ matrix.platform }} shell: bash run: | @@ -4136,9 +4192,21 @@ jobs: resolve_base openclaw \ ghcr.io/nvidia/nemoclaw/sandbox-base:latest \ ghcr.io/nvidia/nemoclaw/sandbox-base - resolve_base dcode \ - ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base:latest \ - ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + dcode_reference="$( + jq -er --arg platform "$PLATFORM" \ + '.platformReferences[$platform]' <<< "$DCODE_BASE_CONTRACT" + )" + [[ "$dcode_reference" =~ ^ghcr[.]io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base@sha256:[a-f0-9]{64}$ ]] || { + echo "::error::dcode base publication returned an invalid exact platform reference" >&2 + exit 1 + } + dcode_digest="${dcode_reference##*@}" + docker buildx imagetools inspect "$dcode_reference" --raw > "$work_dir/dcode-exact.raw" + [[ "sha256:$(sha256sum "$work_dir/dcode-exact.raw" | awk '{print $1}')" == "$dcode_digest" ]] || { + echo "::error::dcode exact base bytes do not match the published platform digest" >&2 + exit 1 + } + printf 'dcode=%s\n' "$dcode_reference" >> "$GITHUB_OUTPUT" - name: Start isolated protected managed-image registry shell: bash @@ -4279,8 +4347,7 @@ jobs: - name: Validate protected managed-image evidence shell: bash run: >- - npx tsx tools/e2e/live-vitest-invocation.mts run - --test-path test/e2e/live/managed-image-multiarch-startup.test.ts + npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/managed-image-multiarch-startup.test.ts - name: Publish exact amd64 protected runtime build cache if: ${{ matrix.platform == 'linux/amd64' }} @@ -4544,17 +4611,12 @@ jobs: if: always() shell: bash run: >- - npx --no-install tsx scripts/checks/run-llama-cpp-dgx-spark-qualification.mts - --cleanup-only - --registry-name "$NEMOCLAW_LLAMA_CPP_QUALIFICATION_REGISTRY" - --run-attempt "$GITHUB_RUN_ATTEMPT" - --run-id "$GITHUB_RUN_ID" + npx --no-install tsx scripts/checks/run-llama-cpp-dgx-spark-qualification.mts --cleanup-only --registry-name "$NEMOCLAW_LLAMA_CPP_QUALIFICATION_REGISTRY" --run-attempt "$GITHUB_RUN_ATTEMPT" --run-id "$GITHUB_RUN_ID" - name: Validate protected llama.cpp evidence shell: bash run: >- - npx --no-install tsx tools/e2e/live-vitest-invocation.mts run - --test-path test/e2e/live/llama-cpp-dgx-spark-qualification.test.ts + npx --no-install tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/llama-cpp-dgx-spark-qualification.test.ts - name: Upload protected llama.cpp evidence if: always() @@ -4595,8 +4657,8 @@ jobs: # assertions without the hosted cache. managed-image-protected-runtime: name: Protected managed-image GPU and local inference - needs: [generate-matrix, managed-image-multiarch-startup] - if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }} + needs: [base-image-publication, generate-matrix, managed-image-multiarch-startup] + if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }} runs-on: linux-amd64-gpu-rtxpro6000-latest-1 timeout-minutes: 300 permissions: @@ -4639,8 +4701,8 @@ jobs: shell: bash run: | set -euo pipefail - [[ "$REPOSITORY" == "NVIDIA/NemoClaw" && "$REF" == "refs/heads/main" && ( "$EVENT_NAME" == "push" || "$EVENT_NAME" == "workflow_dispatch" ) ]] || { - echo "::error::Protected managed-image runtime must run from trusted NVIDIA/NemoClaw main" >&2 + [[ "$REPOSITORY" == "NVIDIA/NemoClaw" && ( ( "$EVENT_NAME" == "push" && "$REF" == "refs/heads/main" ) || ( "$EVENT_NAME" == "workflow_dispatch" && "$REF" == refs/heads/* ) ) ]] || { + echo "::error::Protected managed-image runtime must run from NVIDIA/NemoClaw" >&2 exit 1 } [[ "$CHECKOUT_SHA" =~ ^[a-f0-9]{40}$ && "$BASE_SHA" =~ ^[a-f0-9]{40}$ ]] || { @@ -4730,6 +4792,8 @@ jobs: - id: runtime-bases name: Resolve exact amd64 runtime base images + env: + DCODE_BASE_REF: ${{ needs.base-image-publication.outputs.dcode_base_ref }} shell: bash run: | set -euo pipefail @@ -4773,9 +4837,17 @@ jobs: resolve_base openclaw \ ghcr.io/nvidia/nemoclaw/sandbox-base:latest \ ghcr.io/nvidia/nemoclaw/sandbox-base - resolve_base dcode \ - ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base:latest \ - ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base + [[ "$DCODE_BASE_REF" =~ ^ghcr[.]io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base@sha256:[a-f0-9]{64}$ ]] || { + echo "::error::dcode base publication returned an invalid exact reference" >&2 + exit 1 + } + dcode_digest="${DCODE_BASE_REF##*@}" + docker buildx imagetools inspect "$DCODE_BASE_REF" --raw > "$work_dir/dcode-exact.raw" + [[ "sha256:$(sha256sum "$work_dir/dcode-exact.raw" | awk '{print $1}')" == "$dcode_digest" ]] || { + echo "::error::dcode exact base bytes do not match the published digest" >&2 + exit 1 + } + printf 'dcode=%s\n' "$DCODE_BASE_REF" >> "$GITHUB_OUTPUT" - name: Start isolated protected runtime registry shell: bash @@ -4830,7 +4902,7 @@ jobs: - name: Run all-agent GPU, local inference, rollback, and cleanup qualification env: - NVIDIA_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_API_KEY || '' }} + NVIDIA_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_API_KEY || '' }} shell: bash run: | set -euo pipefail @@ -4881,7 +4953,6 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh - hermes-e2e: needs: generate-matrix if: ${{ needs.generate-matrix.outputs.hermes_selected == 'true' }} @@ -4907,7 +4978,7 @@ jobs: steps: - id: trusted_hermes_swap name: Provision trusted Hermes E2E swap - if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (github.event_name == 'push' || inputs.checkout_sha == '' || (github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && (contains(format(',{0},', inputs.jobs), ',hermes-e2e,') || contains(format(',{0},', inputs.targets), ',hermes-e2e,') || contains(format(',{0},', inputs.jobs), ',hermes-dashboard,') || contains(format(',{0},', inputs.targets), ',hermes-dashboard,')))) }} + if: ${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (github.event_name == 'push' || inputs.checkout_sha == '' || (github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && (contains(format(',{0},', inputs.jobs), ',hermes-e2e,') || contains(format(',{0},', inputs.targets), ',hermes-e2e,') || contains(format(',{0},', inputs.jobs), ',hermes-dashboard,') || contains(format(',{0},', inputs.targets), ',hermes-dashboard,')))) }} shell: /bin/bash --noprofile --norc -e -o pipefail {0} env: BASH_ENV: /dev/null @@ -4929,6 +5000,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - *dockerhub-auth @@ -4951,7 +5023,7 @@ jobs: - name: Run Hermes live Vitest test env: - NVIDIA_INFERENCE_API_KEY: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && 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 || '' }} + NVIDIA_INFERENCE_API_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 || '' }} run: | set -euo pipefail npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/hermes-e2e.test.ts @@ -5015,6 +5087,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - name: Checkout trusted Hermes GPU runtime fixture @@ -5252,7 +5325,6 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh - jetson-nvmap-gpu: needs: generate-matrix if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.allow_jetson_dispatch && (inputs.checkout_repository == '' || inputs.checkout_repository == github.repository) && ((inputs.jobs == '' && inputs.targets == '') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'jetson-nvmap-gpu')))) }} @@ -5315,6 +5387,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - *dockerhub-auth @@ -5352,6 +5425,30 @@ 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 @@ -5414,8 +5511,6 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh - - messaging-providers: needs: generate-matrix if: ${{ contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'messaging-providers') }} @@ -5439,6 +5534,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - *dockerhub-auth @@ -5507,6 +5603,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - *dockerhub-auth @@ -5582,6 +5679,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false - *dockerhub-auth @@ -5627,15 +5725,14 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh - report-to-pr: runs-on: ubuntu-latest timeout-minutes: 15 # This entire workflow is dispatch-only. Keeping selective jobs in `needs` # makes the report wait for and record any requested job without adding # skipped checks to the normal pull_request workflow. - needs: &e2e-result-jobs - [ + needs: + &e2e-result-jobs [ base-image-publication, generate-matrix, retired-selector-compatibility, @@ -5796,6 +5893,7 @@ jobs: with: repository: ${{ inputs.checkout_repository || github.repository }} ref: ${{ inputs.checkout_sha || github.sha }} + fetch-depth: 0 persist-credentials: false sparse-checkout: | ci/onboard-performance-budget.json diff --git a/.github/workflows/managed-images.yaml b/.github/workflows/managed-images.yaml index e324fb7561a..9b78f4faf64 100644 --- a/.github/workflows/managed-images.yaml +++ b/.github/workflows/managed-images.yaml @@ -419,8 +419,21 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 persist-credentials: false + - name: Resolve managed image release identity + id: release + shell: bash + run: | + set -euo pipefail + release="$(git describe --tags --match 'v*' "$CANDIDATE_SHA")" + if [[ ! "$release" =~ ^v[0-9]+([.][0-9]+){1,3}([-.][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then + echo "ERROR: managed image release identity is invalid: $release" >&2 + exit 1 + fi + printf 'value=%s\n' "$release" >> "$GITHUB_OUTPUT" + - name: Reproduce reviewed discovery permission drift shell: bash run: | @@ -457,6 +470,7 @@ jobs: id: base shell: bash env: + AGENT: ${{ matrix.agent }} BASE_DOCKERFILE: ${{ matrix.base_dockerfile }} BASE_ALIAS: ${{ matrix.base_alias }} BASE_REPOSITORY: ${{ matrix.base_repository }} @@ -464,8 +478,55 @@ jobs: CANDIDATE_SHA: ${{ github.event.pull_request.head.sha }} DISPLAY_NAME: ${{ matrix.display_name }} LOCAL_BASE_REFERENCE: nemoclaw-managed-pr/${{ matrix.agent }}-base:${{ github.event.pull_request.head.sha }} + PLATFORM: linux/amd64 run: | set -euo pipefail + write_dcode_resolution() { + [ "$AGENT" = "langchain-deepagents-code" ] || return 0 + local identity_ref="$1" inspect_ref="$2" expected_revision="$3" + local digest="${identity_ref##*@}" + if [ "$identity_ref" != "${BASE_REPOSITORY}@${digest}" ] || + [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "ERROR: DCode base reference is not an exact platform digest." >&2 + exit 1 + fi + if [ "$identity_ref" = "$inspect_ref" ]; then + docker pull --platform "$PLATFORM" "$identity_ref" >/dev/null + fi + local image_json source_revision image_id os architecture glibc_output glibc metadata key + image_json="$(docker image inspect "$inspect_ref")" + source_revision="$(jq -er 'if length == 1 then .[0].Config.Labels["org.opencontainers.image.revision"] else error("not one image") end' <<< "$image_json")" + image_id="$(jq -er '.[0].Id' <<< "$image_json")" + os="$(jq -er '.[0].Os' <<< "$image_json")" + architecture="$(jq -er '.[0].Architecture' <<< "$image_json")" + if [[ ! "$source_revision" =~ ^[0-9a-f]{40}$ ]] || + { [ -n "$expected_revision" ] && [ "$source_revision" != "$expected_revision" ]; } || + [[ ! "$image_id" =~ ^sha256:[0-9a-f]{64}$ ]] || + [ "${os}/${architecture}" != "$PLATFORM" ]; then + echo "ERROR: DCode base image does not match its immutable receipt." >&2 + exit 1 + fi + glibc_output="$(docker run --rm --platform "$PLATFORM" --entrypoint getconf "$inspect_ref" GNU_LIBC_VERSION)" + [[ "$glibc_output" =~ ^glibc\ ([0-9]+[.][0-9]+)$ ]] || { + echo "ERROR: DCode base image returned malformed glibc metadata." >&2 + exit 1 + } + glibc="${BASH_REMATCH[1]}" + [ "$(printf '%s\n' 2.39 "$glibc" | sort -V | head -n 1)" = "2.39" ] || { + echo "ERROR: DCode base image is below glibc 2.39." >&2 + exit 1 + } + metadata="$(jq -cn \ + --arg architecture "$architecture" --arg digest "$digest" \ + --arg glibc "$glibc" --arg image "$BASE_REPOSITORY" \ + --arg imageId "$image_id" --arg os "$os" --arg ref "$identity_ref" \ + --arg revision "$source_revision" \ + '{schema:1,key:"",imageName:$image,ref:$ref,digest:$digest,source:"override",sourceRevision:$revision,imageId:$imageId,os:$os,architecture:$architecture,glibcVersion:$glibc,requireOpenshellSandboxAbi:true,minGlibcVersion:"2.39"}')" + key="$(printf '%s' "$metadata" | sha256sum | awk '{print $1}')" + metadata="$(jq -c --arg key "$key" '.key = $key' <<< "$metadata")" + printf 'resolution_key=%s\n' "$key" >> "$GITHUB_OUTPUT" + printf 'resolution_label=%s\n' "$(printf '%s' "$metadata" | base64 -w0 | tr '+/' '-_' | tr -d '=')" >> "$GITHUB_OUTPUT" + } if [[ ! "$BASE_SHA" =~ ^[0-9a-f]{40}$ || ! "$CANDIDATE_SHA" =~ ^[0-9a-f]{40}$ ]]; then echo "ERROR: PR base resolution requires exact base and candidate commit SHAs." >&2 exit 1 @@ -484,12 +545,17 @@ jobs: local_base_archive="$RUNNER_TEMP/pr-base.docker.tar" local_base_oci_archive="$RUNNER_TEMP/pr-base.oci.tar" local_base_oci="$RUNNER_TEMP/pr-base.oci" + base_labels=() + if [ "$AGENT" = "langchain-deepagents-code" ]; then + base_labels+=(--label "org.opencontainers.image.revision=${CANDIDATE_SHA}") + fi docker buildx build \ --platform linux/amd64 \ --provenance=false \ --sbom=false \ --file "$BASE_DOCKERFILE" \ --tag "$LOCAL_BASE_REFERENCE" \ + "${base_labels[@]}" \ --output "type=docker,dest=${local_base_archive}" \ --output "type=oci,dest=${local_base_oci_archive}" \ . @@ -509,6 +575,10 @@ jobs: printf 'ref=%s\n' "$LOCAL_BASE_REFERENCE" >> "$GITHUB_OUTPUT" printf 'local=true\n' >> "$GITHUB_OUTPUT" printf 'oci=%s@%s\n' "$local_base_oci" "$local_base_oci_digest" >> "$GITHUB_OUTPUT" + write_dcode_resolution \ + "${BASE_REPOSITORY}@${local_base_oci_digest}" \ + "$LOCAL_BASE_REFERENCE" \ + "$CANDIDATE_SHA" printf '### %s PR base\n\nLocally built from `%s` at `%s`.\n' \ "$DISPLAY_NAME" "$BASE_DOCKERFILE" "$CANDIDATE_SHA" \ >> "$GITHUB_STEP_SUMMARY" @@ -552,6 +622,7 @@ jobs: fi printf 'ref=%s\n' "$reference" >> "$GITHUB_OUTPUT" printf 'local=false\n' >> "$GITHUB_OUTPUT" + write_dcode_resolution "$reference" "$reference" "" printf '### %s PR base\n\n`%s`\n' "$DISPLAY_NAME" "$reference" \ >> "$GITHUB_STEP_SUMMARY" @@ -572,27 +643,40 @@ jobs: if: steps.base.outputs.local == 'true' shell: bash env: + AGENT: ${{ matrix.agent }} BASE_IMAGE: ${{ steps.base.outputs.ref }} CANDIDATE_SHA: ${{ github.event.pull_request.head.sha }} DOCKERFILE: ${{ matrix.dockerfile }} IMAGE_REFERENCE: ${{ matrix.image }}:${{ github.event.pull_request.head.sha }} + RELEASE: ${{ steps.release.outputs.value }} + RESOLUTION_KEY: ${{ steps.base.outputs.resolution_key }} + RESOLUTION_LABEL: ${{ steps.base.outputs.resolution_label }} run: | set -euo pipefail # The base resolver loads a changed base into Docker's local image # store. Keep this consumer on that same store: a Buildx container # builder otherwise treats the local-only reference as Docker Hub. + resolution_labels=() + if [ "$AGENT" = "langchain-deepagents-code" ]; then + resolution_labels+=( + --label "com.nvidia.nemoclaw.base-resolution-key=${RESOLUTION_KEY}" + --label "com.nvidia.nemoclaw.base-resolution=${RESOLUTION_LABEL}" + ) + fi docker build \ --platform linux/amd64 \ --file "$DOCKERFILE" \ --tag "$IMAGE_REFERENCE" \ --label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \ --label "org.opencontainers.image.revision=${CANDIDATE_SHA}" \ + --label "org.opencontainers.image.version=${RELEASE}" \ --label "io.nvidia.nemoclaw.agent=${{ matrix.agent }}" \ --label "io.nvidia.nemoclaw.managed-image.contract=1" \ --label "io.nvidia.nemoclaw.managed-image.platform=linux/amd64" \ --label "io.nvidia.nemoclaw.managed-image.startup-profile=1" \ --label "io.nvidia.nemoclaw.managed-image.capabilities=1" \ --label "io.nvidia.nemoclaw.managed-image.cohort=ghrun-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + "${resolution_labels[@]}" \ --build-arg "BASE_IMAGE=${BASE_IMAGE}" \ --build-arg "NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1" \ --build-arg "NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root" \ @@ -611,12 +695,15 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.event.pull_request.head.sha }} + org.opencontainers.image.version=${{ steps.release.outputs.value }} io.nvidia.nemoclaw.agent=${{ matrix.agent }} io.nvidia.nemoclaw.managed-image.contract=1 io.nvidia.nemoclaw.managed-image.platform=linux/amd64 io.nvidia.nemoclaw.managed-image.startup-profile=1 io.nvidia.nemoclaw.managed-image.capabilities=1 io.nvidia.nemoclaw.managed-image.cohort=ghrun-${{ github.run_id }}-${{ github.run_attempt }} + ${{ matrix.agent == 'langchain-deepagents-code' && format('com.nvidia.nemoclaw.base-resolution-key={0}', steps.base.outputs.resolution_key) || '' }} + ${{ matrix.agent == 'langchain-deepagents-code' && format('com.nvidia.nemoclaw.base-resolution={0}', steps.base.outputs.resolution_label) || '' }} build-args: | BASE_IMAGE=${{ steps.base.outputs.ref }} NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 @@ -633,6 +720,9 @@ jobs: IMAGE_REFERENCE: ${{ matrix.image }}:${{ github.event.pull_request.head.sha }} PLATFORM: linux/amd64 PUBLICATION_COHORT: ghrun-${{ github.run_id }}-${{ github.run_attempt }} + RELEASE: ${{ steps.release.outputs.value }} + RESOLUTION_KEY: ${{ steps.base.outputs.resolution_key }} + RESOLUTION_LABEL: ${{ steps.base.outputs.resolution_label }} run: | set -euo pipefail image_json="$(docker image inspect "$IMAGE_REFERENCE")" @@ -659,6 +749,7 @@ jobs: --arg cohort "$PUBLICATION_COHORT" \ --arg image_id "$image_id" \ --arg platform "$PLATFORM" \ + --arg release "$RELEASE" \ --arg revision "$CANDIDATE_SHA" ' length == 1 and .[0].Id == $image_id and @@ -670,11 +761,20 @@ jobs: .[0].Config.Labels["io.nvidia.nemoclaw.managed-image.startup-profile"] == "1" and .[0].Config.Labels["io.nvidia.nemoclaw.managed-image.capabilities"] == "1" and .[0].Config.Labels["io.nvidia.nemoclaw.managed-image.cohort"] == $cohort and - .[0].Config.Labels["org.opencontainers.image.revision"] == $revision + .[0].Config.Labels["org.opencontainers.image.revision"] == $revision and + .[0].Config.Labels["org.opencontainers.image.version"] == $release ' <<< "$image_json" >/dev/null; then echo "ERROR: PR managed image contract does not match the exact build identity." >&2 exit 1 fi + if [ "$AGENT" = "langchain-deepagents-code" ] && + ! jq -e --arg key "$RESOLUTION_KEY" --arg label "$RESOLUTION_LABEL" ' + .[0].Config.Labels["com.nvidia.nemoclaw.base-resolution-key"] == $key and + .[0].Config.Labels["com.nvidia.nemoclaw.base-resolution"] == $label + ' <<< "$image_json" >/dev/null; then + echo "ERROR: Deep Agents Code managed image lost base resolution metadata." >&2 + exit 1 + fi discovery_runtime="/usr/local/lib/nemoclaw/mcp-tool-discovery-runtime" if ! actual_discovery_contract="$( @@ -795,12 +895,15 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.event.pull_request.head.sha }} + org.opencontainers.image.version=${{ steps.release.outputs.value }} io.nvidia.nemoclaw.agent=${{ matrix.agent }} io.nvidia.nemoclaw.managed-image.contract=1 io.nvidia.nemoclaw.managed-image.platform=linux/amd64 io.nvidia.nemoclaw.managed-image.startup-profile=1 io.nvidia.nemoclaw.managed-image.capabilities=1 io.nvidia.nemoclaw.managed-image.cohort=ghrun-${{ github.run_id }}-${{ github.run_attempt }} + ${{ matrix.agent == 'langchain-deepagents-code' && format('com.nvidia.nemoclaw.base-resolution-key={0}', steps.base.outputs.resolution_key) || '' }} + ${{ matrix.agent == 'langchain-deepagents-code' && format('com.nvidia.nemoclaw.base-resolution={0}', steps.base.outputs.resolution_label) || '' }} build-args: | BASE_IMAGE=${{ steps.base.outputs.local == 'true' && 'nemoclaw-pr-base' || steps.base.outputs.ref }} NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 @@ -822,6 +925,9 @@ jobs: COHORT: ghrun-${{ github.run_id }}-${{ github.run_attempt }} DIGEST: ${{ steps.publish.outputs.digest }} IMAGE: ${{ matrix.repository }} + RELEASE: ${{ steps.release.outputs.value }} + RESOLUTION_KEY: ${{ steps.base.outputs.resolution_key }} + RESOLUTION_LABEL: ${{ steps.base.outputs.resolution_label }} run: | set -euo pipefail [[ "$CANDIDATE_SHA" =~ ^[a-f0-9]{40}$ ]] || { @@ -840,7 +946,21 @@ jobs: echo "ERROR: published PR manifest bytes do not match the build digest" >&2 exit 1 } - release="v$(node -p 'require("./package.json").version')" + published_release="$( + docker image inspect \ + --format '{{index .Config.Labels "org.opencontainers.image.version"}}' \ + "$reference" + )" + if [ "$published_release" != "$RELEASE" ]; then + echo "ERROR: published PR image release label does not match the immutable contract." >&2 + exit 1 + fi + if [ "$AGENT" = "langchain-deepagents-code" ] && + { [ "$(docker image inspect --format '{{index .Config.Labels "com.nvidia.nemoclaw.base-resolution-key"}}' "$reference")" != "$RESOLUTION_KEY" ] || + [ "$(docker image inspect --format '{{index .Config.Labels "com.nvidia.nemoclaw.base-resolution"}}' "$reference")" != "$RESOLUTION_LABEL" ]; }; then + echo "ERROR: published Deep Agents Code image lost base resolution metadata." >&2 + exit 1 + fi contract_dir="$RUNNER_TEMP/managed-pr-contract" mkdir -p "$contract_dir" jq -n \ @@ -849,7 +969,7 @@ jobs: --arg digest "$DIGEST" \ --arg image "$IMAGE" \ --arg reference "$reference" \ - --arg release "$release" \ + --arg release "$RELEASE" \ --arg revision "$CANDIDATE_SHA" \ '{ contractVersion: 1, @@ -900,6 +1020,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 persist-credentials: false - name: Set up Node.js @@ -992,6 +1113,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 persist-credentials: false - name: Set up Node.js @@ -1603,8 +1725,25 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 persist-credentials: false + - name: Resolve managed image release identity + id: release + shell: bash + run: | + set -euo pipefail + release="$(git describe --tags --match 'v*' "$GITHUB_SHA")" + if [[ ! "$release" =~ ^v[0-9]+([.][0-9]+){1,3}([-.][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then + echo "ERROR: managed image release identity is invalid: $release" >&2 + exit 1 + fi + if [[ "$GITHUB_REF" == refs/tags/* && "$release" != "${GITHUB_REF#refs/tags/}" ]]; then + echo "ERROR: managed image release identity does not match the release tag." >&2 + exit 1 + fi + printf 'value=%s\n' "$release" >> "$GITHUB_OUTPUT" + - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -1702,6 +1841,41 @@ jobs: )" docker buildx imagetools inspect "$platform_reference" >/dev/null printf 'ref=%s\n' "$platform_reference" >> "$GITHUB_OUTPUT" + if [ "$AGENT" = "langchain-deepagents-code" ]; then + digest="$(jq -er --arg platform "$PLATFORM" '.platformDigests[$platform]' "$CONTRACT")" + source_revision="$(jq -er '.sourceRevision' "$CONTRACT")" + docker pull --platform "$PLATFORM" "$platform_reference" >/dev/null + image_json="$(docker image inspect "$platform_reference")" + image_id="$(jq -er 'if length == 1 then .[0].Id else error("not one image") end' <<< "$image_json")" + os="$(jq -er '.[0].Os' <<< "$image_json")" + architecture="$(jq -er '.[0].Architecture' <<< "$image_json")" + image_revision="$(jq -er '.[0].Config.Labels["org.opencontainers.image.revision"]' <<< "$image_json")" + if [[ ! "$image_id" =~ ^sha256:[0-9a-f]{64}$ ]] || + [ "${os}/${architecture}" != "$PLATFORM" ] || + [ "$image_revision" != "$source_revision" ]; then + echo "ERROR: DCode base image does not match its immutable contract." >&2 + exit 1 + fi + glibc_output="$(docker run --rm --platform "$PLATFORM" --entrypoint getconf "$platform_reference" GNU_LIBC_VERSION)" + [[ "$glibc_output" =~ ^glibc\ ([0-9]+[.][0-9]+)$ ]] || { + echo "ERROR: DCode base image returned malformed glibc metadata." >&2 + exit 1 + } + glibc="${BASH_REMATCH[1]}" + [ "$(printf '%s\n' 2.39 "$glibc" | sort -V | head -n 1)" = "2.39" ] || { + echo "ERROR: DCode base image is below glibc 2.39." >&2 + exit 1 + } + metadata="$(jq -cn \ + --arg architecture "$architecture" --arg digest "$digest" \ + --arg glibc "$glibc" --arg image "$BASE_IMAGE" --arg imageId "$image_id" \ + --arg os "$os" --arg ref "$platform_reference" --arg revision "$source_revision" \ + '{schema:1,key:"",imageName:$image,ref:$ref,digest:$digest,source:"override",sourceRevision:$revision,imageId:$imageId,os:$os,architecture:$architecture,glibcVersion:$glibc,requireOpenshellSandboxAbi:true,minGlibcVersion:"2.39"}')" + key="$(printf '%s' "$metadata" | sha256sum | awk '{print $1}')" + metadata="$(jq -c --arg key "$key" '.key = $key' <<< "$metadata")" + printf 'resolution_key=%s\n' "$key" >> "$GITHUB_OUTPUT" + printf 'resolution_label=%s\n' "$(printf '%s' "$metadata" | base64 -w0 | tr '+/' '-_' | tr -d '=')" >> "$GITHUB_OUTPUT" + fi - name: Log in to GHCR uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -1737,12 +1911,15 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ steps.release.outputs.value }} io.nvidia.nemoclaw.agent=${{ matrix.agent }} io.nvidia.nemoclaw.managed-image.contract=1 io.nvidia.nemoclaw.managed-image.platform=${{ matrix.platform }} io.nvidia.nemoclaw.managed-image.startup-profile=1 io.nvidia.nemoclaw.managed-image.capabilities=1 io.nvidia.nemoclaw.managed-image.cohort=${{ needs.publication-identity.outputs.cohort }} + ${{ matrix.agent == 'langchain-deepagents-code' && format('com.nvidia.nemoclaw.base-resolution-key={0}', steps.base.outputs.resolution_key) || '' }} + ${{ matrix.agent == 'langchain-deepagents-code' && format('com.nvidia.nemoclaw.base-resolution={0}', steps.base.outputs.resolution_label) || '' }} build-args: | BASE_IMAGE=${{ steps.base.outputs.ref }} NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1 @@ -1766,7 +1943,10 @@ jobs: IMAGE: ${{ env.REGISTRY }}/${{ matrix.image }} PLATFORM: ${{ matrix.platform }} PUBLICATION_COHORT: ${{ needs.publication-identity.outputs.cohort }} + RELEASE: ${{ steps.release.outputs.value }} REQUIRED_BINARY: ${{ matrix.required_binary }} + RESOLUTION_KEY: ${{ steps.base.outputs.resolution_key }} + RESOLUTION_LABEL: ${{ steps.base.outputs.resolution_label }} run: | set -euo pipefail if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then @@ -1794,6 +1974,12 @@ jobs: echo "ERROR: managed image OCI user must be uid 0 for OpenShell supervisor initialization: $image_user" >&2 exit 1 fi + if [ "$AGENT" = "langchain-deepagents-code" ] && + { [ "$(docker image inspect --format '{{index .Config.Labels "com.nvidia.nemoclaw.base-resolution-key"}}' "$reference")" != "$RESOLUTION_KEY" ] || + [ "$(docker image inspect --format '{{index .Config.Labels "com.nvidia.nemoclaw.base-resolution"}}' "$reference")" != "$RESOLUTION_LABEL" ]; }; then + echo "ERROR: Deep Agents Code image lost base resolution metadata." >&2 + exit 1 + fi agent_label="$( docker image inspect \ @@ -1830,6 +2016,11 @@ jobs: --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \ "$reference" )" + release_label="$( + docker image inspect \ + --format '{{index .Config.Labels "org.opencontainers.image.version"}}' \ + "$reference" + )" cohort_prefix="ghrun-${GITHUB_RUN_ID}-" cohort_attempt="${PUBLICATION_COHORT#"$cohort_prefix"}" if [[ ! "$GITHUB_RUN_ID" =~ ^[1-9][0-9]{0,19}$ ]] || @@ -1843,7 +2034,8 @@ jobs: [ "$capabilities_label" != "1" ] || [ "$platform_label" != "$PLATFORM" ] || [ "$cohort_label" != "$PUBLICATION_COHORT" ] || - [ "$revision_label" != "$GITHUB_SHA" ]; then + [ "$revision_label" != "$GITHUB_SHA" ] || + [ "$release_label" != "$RELEASE" ]; then echo "ERROR: managed image contract labels do not match the build identity." >&2 exit 1 fi diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index 79881fac983..e18f40b2941 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -22,6 +22,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/agents/hermes/policy-permissive.yaml b/agents/hermes/policy-permissive.yaml index 3ead47d01c8..59a92b1f5c3 100644 --- a/agents/hermes/policy-permissive.yaml +++ b/agents/hermes/policy-permissive.yaml @@ -23,6 +23,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/agents/hermes/runtime-config-guard.py b/agents/hermes/runtime-config-guard.py index bf045573ef8..fdb1222f0aa 100755 --- a/agents/hermes/runtime-config-guard.py +++ b/agents/hermes/runtime-config-guard.py @@ -3117,6 +3117,8 @@ def _seal_shields_locked( hash_file: str, state_file: str, rollback_mode: str, + expected_hermes_device: int | None = None, + expected_hermes_inode: int | None = None, ) -> tuple[str, bool]: """Monotonically contain a mutable Hermes namespace. @@ -3126,6 +3128,10 @@ def _seal_shields_locked( inputs or leaves a root-only unavailable posture. """ + if (expected_hermes_device is None) != (expected_hermes_inode is None): + raise UnsafePathError( + "refusing incomplete provider-fenced Hermes state root identity" + ) if os.path.exists(state_file): raise UnsafePathError("Hermes restart seal is already active") lock_token = secrets.token_hex(32) @@ -3187,8 +3193,29 @@ def _seal_shields_locked( except FileNotFoundError: hermes_lstat = None + if expected_hermes_device is not None and ( + hermes_lstat is None or not stat.S_ISDIR(hermes_lstat.st_mode) + ): + state_data["phase"] = "shields-transition-state-root-drift" + _write_restart_state(state_file, state_data, create=False) + raise UnsafePathError( + "refusing Hermes config root that differs from the provider fence" + ) + if hermes_lstat is not None and stat.S_ISDIR(hermes_lstat.st_mode): - if hermes_lstat.st_dev != parent_st.st_dev: + if expected_hermes_device is not None and ( + hermes_lstat.st_dev != expected_hermes_device + or hermes_lstat.st_ino != expected_hermes_inode + ): + state_data["phase"] = "shields-transition-state-root-drift" + _write_restart_state(state_file, state_data, create=False) + raise UnsafePathError( + "refusing Hermes config root that differs from the provider fence" + ) + if ( + hermes_lstat.st_dev != parent_st.st_dev + and expected_hermes_device is None + ): state_data["phase"] = "shields-transition-cross-device" _write_restart_state(state_file, state_data, create=False) raise UnsafePathError( @@ -3208,7 +3235,16 @@ def _seal_shields_locked( os.fchmod(hermes_fd, 0o700) hermes_st = os.fstat(hermes_fd) - if hermes_st.st_dev != parent_st.st_dev: + if expected_hermes_device is not None and ( + hermes_st.st_dev != expected_hermes_device + or hermes_st.st_ino != expected_hermes_inode + ): + state_data["phase"] = "shields-transition-state-root-drift" + _write_restart_state(state_file, state_data, create=False) + raise UnsafePathError( + "refusing Hermes config root that differs from the provider fence" + ) + if hermes_st.st_dev != parent_st.st_dev and expected_hermes_device is None: state_data["phase"] = "shields-transition-cross-device" _write_restart_state(state_file, state_data, create=False) raise UnsafePathError( @@ -3573,6 +3609,8 @@ def begin_shields_transition( state_file: str, mode: str, rollback_mode: str = "", + expected_hermes_device: int | None = None, + expected_hermes_inode: int | None = None, ) -> tuple[str, bool]: if mode not in ("locked", "mutable"): raise UnsafePathError(f"refusing unsupported Hermes shields transition: {mode}") @@ -3590,6 +3628,8 @@ def begin_shields_transition( hash_file, state_file, rollback_mode or "mutable", + expected_hermes_device, + expected_hermes_inode, ) resumed = _resume_shields_locked(hermes_dir, hash_file, state_file) if resumed is not None: @@ -3599,6 +3639,8 @@ def begin_shields_transition( hash_file, state_file, rollback_mode or "mutable", + expected_hermes_device, + expected_hermes_inode, ) # A fresh managed non-root Hermes start mints exactly one API_SERVER_KEY and @@ -5067,6 +5109,8 @@ def main() -> int: parser.add_argument( "--rollback-shields-mode", choices=("locked", "mutable"), default="" ) + parser.add_argument("--expected-hermes-device", default="") + parser.add_argument("--expected-hermes-inode", default="") parser.add_argument("--startup-owner", action="store_true") parser.add_argument("--mcp-state-exit-code", action="store_true") args = parser.parse_args() @@ -5156,12 +5200,27 @@ def main() -> int: raise UnsafePathError( "begin-shields-transition requires --hash-file, --state-file, and --shields-mode" ) + expected_hermes_device = None + expected_hermes_inode = None + if args.expected_hermes_device or args.expected_hermes_inode: + if not re.fullmatch( + r"[1-9][0-9]*", args.expected_hermes_device + ) or not re.fullmatch( + r"[1-9][0-9]*", args.expected_hermes_inode + ): + raise UnsafePathError( + "begin-shields-transition requires a complete valid Hermes state-root identity" + ) + expected_hermes_device = int(args.expected_hermes_device) + expected_hermes_inode = int(args.expected_hermes_inode) lock_token, original_locked = begin_shields_transition( args.hermes_dir, args.hash_file, args.state_file, args.shields_mode, args.rollback_shields_mode, + expected_hermes_device, + expected_hermes_inode, ) print(f"lock_token={lock_token} original_locked={int(original_locked)}") elif args.action == "apply-shields-transition": diff --git a/agents/langchain-deepagents-code/policy-additions.yaml b/agents/langchain-deepagents-code/policy-additions.yaml index 10fb346dc7b..0c84378b9fb 100644 --- a/agents/langchain-deepagents-code/policy-additions.yaml +++ b/agents/langchain-deepagents-code/policy-additions.yaml @@ -19,6 +19,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/agents/langchain-deepagents-code/start.sh b/agents/langchain-deepagents-code/start.sh index 21195248fc3..ea34c86e545 100755 --- a/agents/langchain-deepagents-code/start.sh +++ b/agents/langchain-deepagents-code/start.sh @@ -146,11 +146,11 @@ unset _NEMOCLAW_SANDBOX_RLIMITS # or when dcode no longer uses inference.local. readonly MANAGED_PROXY_HOST_FILE="/usr/local/share/nemoclaw/dcode-proxy-host" readonly MANAGED_PROXY_PORT_FILE="/usr/local/share/nemoclaw/dcode-proxy-port" -if [ -e /run/nemoclaw/managed-startup-ca-bundle.pem ] \ - || [ -L /run/nemoclaw/managed-startup-ca-bundle.pem ]; then - readonly MANAGED_FETCH_CA_BUNDLE_FILE="/run/nemoclaw/managed-startup-ca-bundle.pem" -else +if [ -e /etc/openshell-tls/ca-bundle.pem ] \ + || [ -L /etc/openshell-tls/ca-bundle.pem ]; then readonly MANAGED_FETCH_CA_BUNDLE_FILE="/etc/openshell-tls/ca-bundle.pem" +else + readonly MANAGED_FETCH_CA_BUNDLE_FILE="/run/nemoclaw/managed-startup-ca-bundle.pem" fi readonly MANAGED_PROXY_OWNER_UID=0 @@ -229,6 +229,12 @@ validate_managed_fetch_ca_bundle() { PROXY_HOST="$(read_managed_proxy_value "$MANAGED_PROXY_HOST_FILE" "host")" PROXY_PORT="$(read_managed_proxy_value "$MANAGED_PROXY_PORT_FILE" "port")" validate_managed_fetch_ca_bundle +if [ -e "$MANAGED_FETCH_CA_BUNDLE_FILE" ]; then + : "${SSL_CERT_FILE:=$MANAGED_FETCH_CA_BUNDLE_FILE}" + : "${REQUESTS_CA_BUNDLE:=$MANAGED_FETCH_CA_BUNDLE_FILE}" + : "${NODE_EXTRA_CA_CERTS:=$MANAGED_FETCH_CA_BUNDLE_FILE}" + export SSL_CERT_FILE REQUESTS_CA_BUNDLE NODE_EXTRA_CA_CERTS +fi unset NEMOCLAW_PROXY_HOST NEMOCLAW_PROXY_PORT # Generic proxy fallbacks are outside the managed dcode contract and may carry # host credentials even after the scheme-specific proxy values are normalized. diff --git a/agents/openclaw/policy-permissive.yaml b/agents/openclaw/policy-permissive.yaml index 60efda4f372..9f33213ee32 100644 --- a/agents/openclaw/policy-permissive.yaml +++ b/agents/openclaw/policy-permissive.yaml @@ -19,6 +19,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index e7aa5cf0313..c9b0c1938cb 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -24,7 +24,7 @@ "src/lib/inference/web-search.ts": 21, "src/lib/messaging/channels/index.ts": 25, "src/lib/onboard/gateway-binding.ts": 52, - "src/lib/runner.ts": 87, + "src/lib/runner.ts": 86, "src/lib/security/redact.ts": 54, "src/lib/state/onboard-session.ts": 37, "src/lib/state/registry.ts": 101, diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 43a83f06f1c..19c1200f60c 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,7 +8,7 @@ "test/generate-openclaw-config.test.ts": 1907, "test/install-preflight.test.ts": 3025, "test/nemoclaw-start.test.ts": 4671, - "test/onboard-messaging.test.ts": 2028, + "test/onboard-messaging.test.ts": 2023, "test/onboard-selection.test.ts": 4177 } } diff --git a/docs/deployment/sandbox-hardening.mdx b/docs/deployment/sandbox-hardening.mdx index 36dfd4fc597..b3a2fe9bd5a 100644 --- a/docs/deployment/sandbox-hardening.mdx +++ b/docs/deployment/sandbox-hardening.mdx @@ -12,6 +12,13 @@ agent-variants: ["openclaw"] --- The NemoClaw sandbox image applies several security measures to reduce the attack surface and limit damage from untrusted workloads. +## 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. +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. + ## Removed Unnecessary Tools NemoClaw explicitly purges build toolchains (`gcc`, `g++`, `make`) and network probes (`netcat`) from the runtime image. diff --git a/docs/get-started/quickstart-hermes.mdx b/docs/get-started/quickstart-hermes.mdx index aa7be9c4575..61293de4490 100644 --- a/docs/get-started/quickstart-hermes.mdx +++ b/docs/get-started/quickstart-hermes.mdx @@ -61,6 +61,9 @@ Review the [Prerequisites](prerequisites) before you begin. If you accept Express setup, wait for the installer to finish, then continue with **Confirm the Sandbox Is Ready**; Express selects the provider and model non-interactively. 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. + Invalid or inconsistent catalog evidence fails closed before sandbox creation. @@ -211,7 +214,7 @@ Use these details when your first-run path needs more control. On supported non-N1x express platforms, enter `n` to continue with interactive onboarding when you want to select the agent or other settings yourself. On N1x, declining the preview or setting only `NEMOCLAW_NO_EXPRESS=1` stops installation before onboarding. Accept the preview, or set `NEMOCLAW_PROVIDER=install-vllm` before installation to provide the required explicit managed-vLLM intent. - The first Hermes build can take several minutes because NemoClaw builds the Hermes sandbox base image when it is not already cached. + The first Hermes start can take several minutes while OpenShell pulls the exact managed image when it is not already cached. The N1x preview selects one-host managed vLLM with `nvidia/Qwen3.6-35B-A3B-NVFP4`. Refer to [Set Up vLLM](../inference/local-inference/set-up-vllm) for managed model profiles and headless setup. Refer to [Set Up vLLM on Two DGX Stations](../inference/local-inference/set-up-vllm-on-two-dgx-stations) for the Deferred paired workflow. @@ -283,7 +286,7 @@ Use these details when your first-run path needs more control. Use the provider variables from [Choose an Inference Provider](../inference/learn-and-choose/choose-inference-provider) when you choose another provider. Set `NEMOCLAW_WEB_SEARCH_PROVIDER=none` to disable web search explicitly. When the selector is unset, Hermes enables Tavily automatically when `TAVILY_API_KEY` is available and ignores `BRAVE_API_KEY`. - Changing or disabling Tavily requires sandbox recreation because the backend, credential attachment, and policy selection are build-time inputs. + Changing or disabling Tavily requires sandbox recreation because the backend, credential attachment, and policy selection are startup-profile inputs. Rerun onboarding with the new selection and accept recreation, or pass `--recreate-sandbox`. @@ -320,7 +323,7 @@ Use these details when your first-run path needs more control. ``` The onboard flow starts both port forwards automatically. - For a new sandbox, NemoClaw reserves the selected dashboard loopback port through sandbox preparation and the image build. + For a new sandbox, NemoClaw reserves the selected dashboard loopback port through sandbox preparation and creation. If another listener claims the port before NemoClaw binds the reservation, NemoClaw selects another port before changing sandbox resources. If OpenShell returns the exact `sandbox is not ready` response, NemoClaw waits 5 seconds and retries the affected forward up to 12 times. The readiness-specific delays total at most 1 minute and preserve the existing sandbox and selected host port. diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index c2c9d4e3130..45167aa3a80 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -64,6 +64,9 @@ Review the [Prerequisites](prerequisites) before you begin. If you accept Express setup, wait for the installer to finish, then continue with **Confirm the Sandbox Is Ready**; Express selects the provider and model non-interactively. 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. + Invalid or inconsistent catalog evidence fails closed before sandbox creation. @@ -163,7 +166,7 @@ After the terminal smoke checks, onboarding runs `dcode --version` and compares Fresh and resumed onboarding exit nonzero instead of reporting the runtime ready when the installed version is too old, uses an incompatible version scheme, or cannot be verified. If the version check fails, review the reported version error and run `nemo-deepagents rebuild` before resuming onboarding. NemoClaw writes `/sandbox/.deepagents/config.toml` with an OpenAI-compatible provider pointed at `https://inference.local/v1`, uses a scoped placeholder API key for that managed route, and sets `use_responses_api = false` for Chat Completions compatibility. -When onboarding records a reasoning effort on a `compatible-endpoint` route that uses `openai-completions`, the managed image bakes that value into a root-owned file and Deep Agents Code model requests carry it as an `extra_body.reasoning_effort` request parameter. +When onboarding records a reasoning effort on a `compatible-endpoint` route that uses `openai-completions`, managed startup writes that value to a root-owned file and Deep Agents Code model requests carry it as an `extra_body.reasoning_effort` request parameter. Leave `NEMOCLAW_REASONING_EFFORT` unset to keep the endpoint's own default. Deep Agents Code has no runtime `inference set` path, so re-onboard the sandbox with `nemo-deepagents onboard --fresh --name --recreate-sandbox` to change the recorded effort. When you use NVIDIA Endpoints without selecting another model, new Deep Agents Code sandboxes default to `nvidia/nemotron-3-ultra-550b-a55b`. diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index f9d06b98e4b..8636a36378f 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -50,6 +50,9 @@ Review the [Prerequisites](prerequisites) before you begin. Choose an inference provider and model, then provide its credential when prompted. 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. + Invalid or inconsistent catalog evidence fails closed before sandbox creation. 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. @@ -156,7 +159,7 @@ Use these details when your first-run path needs more control. Export the relevant API key before starting the installer when you do not want the wizard to prompt for it. Refer to [Choose an Inference Provider](../inference/learn-and-choose/choose-inference-provider) for provider requirements, model choices, and local-server setup. - Web search and messaging are optional build-time choices. + Web search and messaging are optional onboarding choices. Add them when you need them, then rerun onboarding and accept sandbox recreation when you change those choices later. Refer to [Choose Messaging Channels](../manage-sandboxes/messaging-channels/choose-messaging-channels) and [Network Policies](../network-policy/approve-network-requests) before enabling them. @@ -256,12 +259,12 @@ Use these details when your first-run path needs more control. After you apply the configuration, routine editing ends. If inference setup fails and offers a `back` recovery action, you can return to provider and model selection and then review the updated configuration again. - NemoClaw registers inference, prompts for optional web search and messaging channels, builds and starts the sandbox, sets up OpenClaw, and applies the selected network policy tier and presets. + NemoClaw registers inference, prompts for optional web search and messaging channels, prepares the managed startup profile, starts the sandbox from the exact managed image, sets up OpenClaw, and applies the selected network policy tier and presets. - Onboarding builds the sandbox image with a managed `NEMOCLAW_DISABLE_DEVICE_AUTH=1` compatibility setting so the dashboard is usable during setup. + Onboarding includes a managed `NEMOCLAW_DISABLE_DEVICE_AUTH=1` compatibility setting in the startup profile so the dashboard is usable during setup. NemoClaw records that this value came from onboarding rather than reporting it as an operator-selected opt-out. - This build-time setting is baked into the image and setting it after onboarding does not affect an existing sandbox. + This setting is fixed when the sandbox is created; setting it in the host environment afterward does not affect that sandbox. If registered sandboxes already exist, the installer prepares the current NemoClaw CLI without replacing OpenShell and requires a fresh backup of every registered sandbox before it changes the gateway. @@ -314,7 +317,7 @@ Use these details when your first-run path needs more control. For an OpenAI-compatible HTTP endpoint on `localhost`, `127.0.0.1`, or `[::1]`, press Enter to select no authentication when the endpoint uses the port selected by `NEMOCLAW_VLLM_PORT` (`8000` by default) or port `11434`. Port `11435` supports this mode only when `NEMOCLAW_OLLAMA_PROXY_PORT` uses a different free port. - After you enter a sandbox name, the wizard asks for final confirmation before it registers the provider, prompts for integrations, and builds the sandbox image. + After you enter a sandbox name, the wizard asks for final confirmation before it registers the provider, prompts for integrations, and creates the sandbox. ```text ────────────────────────────────────────────────── @@ -327,7 +330,7 @@ Use these details when your first-run path needs more control. Managed tools: none Messaging: none Sandbox name: my-gpt-claw - Note: Sandbox build typically takes 5–15 minutes on this host. + Note: Sandbox creation can take a few minutes on first run. ────────────────────────────────────────────────── Web search and messaging channels will be prompted next. Choose an action: @@ -353,7 +356,7 @@ Use these details when your first-run path needs more control. After confirmation, NemoClaw registers the selected provider with the OpenShell gateway and sets the `inference.local` route. The wizard asks whether to enable web search and offers Brave Search or Tavily Search. Provide `BRAVE_API_KEY` for Brave Search or `TAVILY_API_KEY` for Tavily Search when prompted. - NemoClaw validates the selected key before it builds the sandbox, registers a sandbox-scoped OpenShell provider, and writes only an OpenShell resolver placeholder into the OpenClaw configuration. + NemoClaw validates the selected key before it creates the sandbox, registers a sandbox-scoped OpenShell provider, and writes only an OpenShell resolver placeholder into the OpenClaw configuration. OpenShell replaces the placeholder with the real key at egress. For non-interactive onboarding, select the provider explicitly and export its key. @@ -373,13 +376,13 @@ Use these details when your first-run path needs more control. The onboarding flow also offers Telegram, Discord, Slack, WeChat, WhatsApp, Microsoft Teams, and Google Chat. Press a channel number to toggle it, then press Enter to continue. Leave every channel unselected to skip messaging setup. - When a channel accepts pasted credentials, NemoClaw validates the credential input before it builds the sandbox image. + When a channel accepts pasted credentials, NemoClaw validates the credential input before it creates the sandbox. For example, Slack bot tokens must start with `xoxb-`. WeChat, WhatsApp, Microsoft Teams, and Google Chat are experimental. Refer to [Choose Messaging Channels](../manage-sandboxes/messaging-channels/choose-messaging-channels) before enabling them. - After the sandbox image builds and OpenClaw starts, NemoClaw asks which network policy tier to apply. - Web search and messaging selections happen first so the sandbox image and policy suggestions stay aligned. + After the managed image starts OpenClaw, NemoClaw asks which network policy tier to apply. + Web search and messaging selections happen first so the startup profile and policy suggestions stay aligned. The default Balanced tier includes common development presets, such as npm, PyPI, Hugging Face, and Homebrew, plus the matching `brave` or `tavily` preset. Add the `weather` preset explicitly for read-only weather lookups. OpenClaw sandboxes also receive the `openclaw-pricing` preset automatically so session-cost records can populate without manual configuration. @@ -441,7 +444,7 @@ Use these details when your first-run path needs more control. The wizard starts a background dashboard port forward and prints its URL in the ready summary. The default host port is `18789`. When that port is occupied, NemoClaw uses the next free dashboard port, such as `18790`, and includes the port in the URL. - For a new sandbox, NemoClaw reserves the selected loopback port through sandbox preparation and the image build. + For a new sandbox, NemoClaw reserves the selected loopback port through sandbox preparation and creation. If another listener claims the port before NemoClaw binds the reservation, NemoClaw selects another port before changing sandbox resources. If OpenShell returns the exact `sandbox is not ready` response, NemoClaw waits 5 seconds and retries the dashboard forward up to 12 times. The readiness-specific delays total at most 1 minute and preserve the existing sandbox and selected port. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index 3fd85f2ac7d..d8e87749b9a 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -225,12 +225,12 @@ The context tells the agent to try allowed network and filesystem operations bef The Hermes integration follows the generic agent-manifest path instead of the OpenClaw plugin package path. The manifest declares Hermes' binary, health probe, config directory, state directories, and OpenAI-compatible API endpoint. Messaging channel availability is declared by each channel manifest's `supportedAgents` list under `src/lib/messaging/channels/`, not by the Hermes agent manifest. -The build-time config generator turns NemoClaw onboarding choices into Hermes YAML and environment files, and the Hermes plugin manifest exposes NemoClaw tools and an `on_session_start` hook. +The configuration generator turns NemoClaw onboarding choices into Hermes YAML and environment files, and the Hermes plugin manifest exposes NemoClaw tools and an `on_session_start` hook. The Deep Agents integration follows the generic agent-manifest path for terminal runtimes. The manifest declares the `dcode` binary, smoke checks, config directory, state directories, and OpenAI-compatible inference route. -The build-time config generator turns NemoClaw onboarding choices into `config.toml`, and the managed launchers enforce the supported credential, MCP, tracing, and sandbox boundaries before `dcode` starts. +The configuration generator turns NemoClaw onboarding choices into `config.toml`, and the managed launchers enforce the supported credential, MCP, tracing, and sandbox boundaries before `dcode` starts. ## NemoClaw Blueprint @@ -309,13 +309,12 @@ The maintained onboarding path for this agent does not consume the component. ## Sandbox Environment - -Normal NemoClaw onboarding builds from the [`ghcr.io/nvidia/nemoclaw/sandbox-base`](https://github.com/NVIDIA/NemoClaw/pkgs/container/nemoclaw%2Fsandbox-base) base image and layers the NemoClaw runtime Dockerfile on top. - - -Deep Agents onboarding builds from the agent-specific `agents/langchain-deepagents-code/Dockerfile.base` image and layers the managed Deep Agents runtime Dockerfile on top. -That base installs Node, Python, shell tools, and the hash-locked `deepagents-code` package needed by the terminal harness. - +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. +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. The direct blueprint runner still carries a pinned OpenShell Community OpenClaw image for legacy `openshell sandbox create --from` compatibility. @@ -399,9 +398,9 @@ The following environment variables configure optional services and local access | `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) you provide before `$$nemoclaw onboard`. Stored as an OpenShell provider; never passed directly to the sandbox. | | `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`) required for Socket Mode. Stored alongside `SLACK_BOT_TOKEN` during onboarding. | | `SLACK_ALLOWED_USERS` | Comma-separated Slack member IDs for DM and channel `@mention` user allowlisting. | -| `SLACK_ALLOWED_CHANNELS` | Comma-separated Slack channel IDs where channel `@mention` events are enabled (e.g. `C012AB3CD,C987ZY6XW`). Baked into the sandbox image at build time. Combine with `SLACK_ALLOWED_USERS` to restrict both channel and member. | +| `SLACK_ALLOWED_CHANNELS` | Comma-separated Slack channel IDs where channel `@mention` events are enabled (e.g. `C012AB3CD,C987ZY6XW`). Included in the generated sandbox configuration during onboarding. Combine with `SLACK_ALLOWED_USERS` to restrict both channel and member. | | `CHAT_UI_URL` | URL for the optional chat UI endpoint. | -| `NEMOCLAW_DISABLE_DEVICE_AUTH` | Build-time-only toggle that disables gateway device pairing when set to `1` before the sandbox image is created. | +| `NEMOCLAW_DISABLE_DEVICE_AUTH` | Onboarding-time toggle that disables gateway device pairing when set to `1` before the sandbox is created. Stock managed-image onboarding carries it in the identity-bound startup profile; explicit custom Dockerfile onboarding carries it into the custom image. | | `TELEGRAM_BOT_TOKEN` | Telegram bot token you provide before `$$nemoclaw onboard`. OpenShell stores it in a provider; the sandbox receives placeholders, not the raw secret. | diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 0183a4fab55..3e2460bcb71 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -276,7 +276,7 @@ Display names are accepted when they identify exactly one profile, but stable ID ### `$$nemoclaw onboard` Run the interactive setup wizard (recommended for new installs). -The wizard creates an OpenShell gateway, registers inference providers, builds the sandbox image, and creates the sandbox. +The wizard creates an OpenShell gateway, registers inference providers, selects the exact managed image (or builds an explicit custom Dockerfile), and creates the sandbox. Use this command for new installs and for recreating a sandbox after changes to policy or configuration. ```bash @@ -452,7 +452,7 @@ It also bypasses locally recorded sandbox base-image resolution metadata and rer `--fresh` takes precedence over a base-image hint carried from a rebuild, so NemoClaw does not use that recorded hint. The installer also accepts `--fresh` and forwards it to `$$nemoclaw onboard`, which skips automatic resume detection. `--resume` and `--fresh` are mutually exclusive. -For an existing completed sandbox, use `--fresh --name --recreate-sandbox` when you intentionally want onboarding to replace that sandbox with a new provider, model, agent, or build-time setting. +For an existing completed sandbox, use `--fresh --name --recreate-sandbox` when you intentionally want onboarding to replace that sandbox with a new provider, model, agent, or startup setting. Use `$$nemoclaw rebuild` when you want NemoClaw to recreate the sandbox from its recorded registry metadata without changing those selections. #### `--tool-disclosure ` @@ -811,7 +811,7 @@ The `$$nemoclaw onboard --help` output lists installed runtime names inline, and -Use `--agents ` to declare secondary OpenClaw agents, `agents.defaults`, and main-agent overrides in a checked-in manifest that NemoClaw bakes into the sandbox image at build time. +Use `--agents ` to declare secondary OpenClaw agents, `agents.defaults`, and main-agent overrides in a checked-in manifest that NemoClaw includes in the generated sandbox configuration. Refer to [Declarative Multi-Agent Manifest](../configure-agents/declarative-agents-manifest) for the schema and OpenClaw-native sub-agent field semantics. @@ -828,10 +828,10 @@ NemoClaw allocates each Hermes sandbox's OpenAI-compatible API port from that ra If you enable Slack during onboarding, the wizard collects both the Bot Token (`SLACK_BOT_TOKEN`) and the App-Level Token (`SLACK_APP_TOKEN`). Socket Mode requires both tokens. The app-level token is stored in a dedicated `slack-app` OpenShell provider and forwarded to the sandbox alongside the bot token. -The wizard also accepts optional `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` values so you can restrict Slack DMs, channel `@mention` users, and channel IDs before the sandbox image is built. +The wizard also accepts optional `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` values so you can restrict Slack DMs, channel `@mention` users, and channel IDs before the sandbox is created. If you enable Discord during onboarding, the wizard can also prompt for a Discord Server ID, whether the bot should reply only to `@mentions` or to all messages in that server, and an optional Discord User ID. -NemoClaw bakes those values into the sandbox image as Discord guild workspace config so the bot can respond in the selected server, not just in DMs. +NemoClaw includes those values in the generated Discord guild workspace configuration so the bot can respond in the selected server, not just in DMs. If you leave the Discord User ID blank, the guild config omits the user allowlist and any member of the configured server can message the bot. Guild responses remain mention-gated by default unless you opt into all-message replies. If `DISCORD_SERVER_ID` is set and `DISCORD_REQUIRE_MENTION` is unset, NemoClaw records the existing mention-only default (`DISCORD_REQUIRE_MENTION=1`). @@ -926,6 +926,12 @@ The poll count is clamped to a minimum of `1` so the health probe always runs at #### `--from ` +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. +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. The supplied Dockerfile defines the complete sandbox image, and NemoClaw does not layer it on top of the stock managed runtime. The entire parent directory of the specified file is used as the Docker build context, so any files your Dockerfile references (scripts, config, etc.) must live alongside it. @@ -1604,7 +1610,7 @@ Hermes can restart its gateway when it applies a configuration change. Use `--re -For Deep Agents sandboxes, `config set` is unavailable because the `dcode` configuration is baked into the sandbox image at build time. +For Deep Agents sandboxes, `config set` is unavailable because managed startup (or an explicit custom image build) materializes the `dcode` configuration as image-owned state. Run `$$nemoclaw onboard --agent dcode --name --fresh` when you need to change it. Use `$$nemoclaw config get` to read the current values. @@ -2953,7 +2959,7 @@ As with `channels add`, `NEMOCLAW_NON_INTERACTIVE=1` or a run without a terminal `channels start` and `channels stop` follow the same rule. If you omit the required `` argument, the CLI prints the `channels remove ` usage with the supported channel list. -Host-side removal is the supported path because agent channel config is baked into the container image at build time (`/sandbox/.openclaw/openclaw.json` for OpenClaw and `/sandbox/.hermes/.env` for Hermes); agent-specific channel removals inside the sandbox would modify the running config but not persist changes across rebuilds. +Host-side removal is the supported path because managed startup (or an explicit custom image build) materializes agent channel config as image-owned state (`/sandbox/.openclaw/openclaw.json` for OpenClaw and `/sandbox/.hermes/.env` for Hermes); agent-specific channel removals inside the sandbox would modify the running config but not persist changes across rebuilds. ### `$$nemoclaw channels stop ` @@ -3817,7 +3823,8 @@ NemoClaw does not truncate or rename a registered sandbox identity. Follow [Update Sandboxes](../manage-sandboxes/operate-sandboxes/update-sandboxes) to transfer state to a compatible replacement before you rerun the command. Each rebuild reuses the same workspace backup-and-restore flow as `$$nemoclaw rebuild`, so workspace files survive the upgrade. -If the registry is unreachable (offline or firewalled hosts), NemoClaw falls back to the unpinned `:latest` tag and reports that the digest could not be resolved instead of failing. +If the registry or required managed-image catalog evidence is unavailable, NemoClaw fails closed instead of selecting an unpinned image. +Restore registry access, then rerun the command so NemoClaw can validate the exact image digest. During installer recovery, a registered sandbox that is not Ready can also be rebuilt from its validated latest backup. That recovery requires a NemoClaw-managed image fingerprint or the installer's explicit confirmation for a listed pre-fingerprint OpenClaw or Hermes entry. The legacy confirmation never overrides recorded custom-image evidence. @@ -4927,16 +4934,16 @@ OpenClaw-specific onboarding configuration: | `NEMOCLAW_WEB_SEARCH_PROVIDER` | `brave`, `tavily`, or `none` | Selects Brave Search or Tavily Search in non-interactive onboarding, or disables web search explicitly. When unset, `BRAVE_API_KEY` implicitly selects Brave before `TAVILY_API_KEY` can implicitly select Tavily. | | `BRAVE_API_KEY` | Brave Search API key | Supplies and implicitly selects Brave Search when no web search provider is set. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | | `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no provider is set and no Brave key is available. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | -| `NEMOCLAW_AGENT_TIMEOUT` | positive integer (seconds) | Build-time setting that overrides `agents.defaults.timeoutSeconds` and `models.providers..timeoutSeconds` in the built OpenClaw config. Set it before onboarding builds the sandbox image. Setting it only for a later `$$nemoclaw agent` invocation does not change the existing image. Raise for slow inference. | +| `NEMOCLAW_AGENT_TIMEOUT` | positive integer (seconds) | Onboarding setting that overrides `agents.defaults.timeoutSeconds` and `models.providers..timeoutSeconds` in the generated OpenClaw config. Set it before onboarding creates or recreates the sandbox. Setting it only for a later `$$nemoclaw agent` invocation does not change the existing sandbox. Raise for slow inference. | | `NEMOCLAW_MCP_SHADOW_DIAGNOSTICS` | literal `1` to enable | Forwards opt-in successful Streamable HTTP MCP timing diagnostics to a newly created or rebuilt OpenClaw sandbox. It does not change timeouts, retries, requests, or responses. Unset it and rebuild after evidence collection to restore failure-only logging. Other values are ignored. | | `NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS` | positive number of seconds | Sets the post-pairing poll cadence for the in-sandbox OpenClaw auto-pair watcher. Defaults to `5` so late allowlisted CLI and browser scope upgrades are approved before clients time out. Raise only on load-sensitive gateways. | | `NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS` | positive integer | Sets how many fast polls run after the watcher observes a fresh allowlisted scope-upgrade request. Defaults to `5`; set lower only when you need to reduce gateway polling. | | `NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS` | positive number of seconds | Sets the fast-reentry interval after a fresh allowlisted scope-upgrade request. Defaults to `1`. | -| `NEMOCLAW_CONTEXT_WINDOW` | positive integer (tokens) | Overrides the model's context-window value in the built OpenClaw config. | -| `NEMOCLAW_MAX_TOKENS` | positive integer (tokens) | Overrides the model's `maxTokens` in the built OpenClaw config. | -| `NEMOCLAW_REASONING` | `true` or `false` | Overrides the model's reasoning-mode flag in the built OpenClaw config. | +| `NEMOCLAW_CONTEXT_WINDOW` | positive integer (tokens) | Overrides the model's context-window value in the generated OpenClaw config. | +| `NEMOCLAW_MAX_TOKENS` | positive integer (tokens) | Overrides the model's `maxTokens` in the generated OpenClaw config. | +| `NEMOCLAW_REASONING` | `true` or `false` | Overrides the model's reasoning-mode flag in the generated OpenClaw config. | | `NEMOCLAW_REASONING_EFFORT` | `low`, `medium`, `high`, or `default` | Applies only to the `compatible-endpoint` provider with the `openai-completions` API. A `low`, `medium`, or `high` value writes `params.extra_body.reasoning_effort`; unset or `default` leaves the endpoint's own default in place. Onboarding parses every explicit value before provider effects and rejects an invalid value or provider/API mismatch before route, policy, sandbox, or registry mutation. During `inference set`, NemoClaw rejects every explicit value, including `default`, before mutation unless the resulting route is compatible. Without an explicit effort input, switching to another provider or API family clears a recorded effort. An ordinary restart preserves the persisted runtime state instead of replaying the image's original value. | -| `NEMOCLAW_AGENT_HEARTBEAT_EVERY` | duration with `s`, `m`, or `h` suffix (for example `30m`, `1h`, or `0m`) | Overrides `agents.defaults.heartbeat.every` in the built OpenClaw config. Set `0m` to disable periodic agent turns. | +| `NEMOCLAW_AGENT_HEARTBEAT_EVERY` | duration with `s`, `m`, or `h` suffix (for example `30m`, `1h`, or `0m`) | Overrides `agents.defaults.heartbeat.every` in the generated OpenClaw config. Set `0m` to disable periodic agent turns. | | `NEMOCLAW_EXTRA_AGENTS_JSON` | JSON array of OpenClaw secondary-agent entries | Adds secondary agents to `agents.list`. Refer to [Extra OpenClaw agents](#extra-openclaw-agents) for the entry schema, path constraints, and validation rules. | @@ -4953,7 +4960,7 @@ Hermes-specific onboarding configuration: | `NEMOCLAW_NOUS_AUTH_METHOD` | same as `NEMOCLAW_HERMES_AUTH_METHOD` | Nous-specific alias for Hermes Provider authentication selection. | | `NEMOCLAW_HERMES_TOOL_GATEWAYS` | comma-separated list | Selects managed Hermes tool gateways in non-interactive onboarding. Valid values are `nous-web`, `nous-image`, `nous-audio`, `nous-browser`, and `nous-code`; the `nous-` prefix is optional. Unknown values fail before sandbox creation. | | `NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS` | comma-separated list | Back-compatible alias for `NEMOCLAW_HERMES_TOOL_GATEWAYS`. | -| `NEMOCLAW_CONTEXT_WINDOW` | positive integer, at least `64000` tokens | Overrides `model.context_length` in the built Hermes config. Fresh and resumed Local Ollama onboarding, including sandbox rebuilds, must verify a loaded runtime context at least as large as this value. | +| `NEMOCLAW_CONTEXT_WINDOW` | positive integer, at least `64000` tokens | Overrides `model.context_length` in the generated Hermes config. Fresh and resumed Local Ollama onboarding, including sandbox rebuilds, must verify a loaded runtime context at least as large as this value. | | `NEMOCLAW_EXTRA_PLACEHOLDER_KEYS` | whitespace- or comma-separated list of upper-snake env keys | Adds operator-supplied OpenShell provider rows so per-profile credentials such as `TELEGRAM_BOT_TOKEN_AGENT_A` flow through the same out-of-process placeholder injection that the canonical channel tokens use, instead of being baked into each Hermes profile `.env` as raw text. Refer to [Extra placeholder keys](#extra-placeholder-keys) for the entry shape and validation rules. | @@ -4971,7 +4978,8 @@ export TELEGRAM_BOT_TOKEN_AGENT_B= $$nemoclaw onboard --agent hermes ``` -For each entry, NemoClaw registers a generic OpenShell provider row that resolves the named env to its operator-supplied value at egress time. +For each entry, NemoClaw registers an OpenShell provider with the endpointless `nemoclaw-mcp-v1` profile. +OpenShell resolves the named credential placeholder to the operator-supplied value at egress. The Hermes profile `.env` files are operator-owned: write `${TELEGRAM_BOT_TOKEN_AGENT_A}` (or the matching placeholder for each entry) into the per-profile `.env` so the in-sandbox Hermes process inherits the OpenShell placeholder instead of a raw token. NemoClaw never reads, writes, or rewrites these `.env` files; verify after onboarding that each profile's `.env` references the placeholder and that no raw bot token value sits on disk. @@ -4991,10 +4999,10 @@ Export the credential before running `$$nemoclaw onboard` for that profile. #### Extra OpenClaw agents -Set `NEMOCLAW_EXTRA_AGENTS_JSON` to either a JSON array of secondary-agent entries, or an object payload of the form `{"agents": [...], "defaults": {...}, "main": {...}}`, to bake them into `agents.list[]` at image build time. +Set `NEMOCLAW_EXTRA_AGENTS_JSON` to either a JSON array of secondary-agent entries, or an object payload of the form `{"agents": [...], "defaults": {...}, "main": {...}}`, to include them in `agents.list[]` during managed startup or an explicit custom image build. Each entry must declare `id` and `tools`; `workspace`, `agentDir`, `subagents`, `description`, and `model` are optional. The generator always writes the canonical `main` entry first with `default: true`, so secondary agents cannot displace the primary agent. -Malformed JSON or invalid entries fail the image build with a structured error. +Malformed JSON or invalid entries fail onboarding with a structured error. Field rules: @@ -5004,8 +5012,8 @@ Field rules: - `tools` must declare a non-empty `allow[]` or `deny[]`; nothing is implicitly granted. - `model`, when set, must be a `"provider/model"` string whose provider portion matches the primary onboard provider. - `default: true` is rejected because the primary agent is the only default. -- Allowed entry fields: `id`, `workspace`, `agentDir`, `tools`, `subagents`, `description`, `model`. Any other key fails the image build (no implicit credential or env pass-through). -- Allowed `tools` fields: `profile`, `allow`, `deny`. Allowed per-agent `subagents` fields: `delegationMode`, `allowAgents`, `model`, `thinking`, `requireAgentId`. Any other nested key fails the image build. +- Allowed entry fields: `id`, `workspace`, `agentDir`, `tools`, `subagents`, `description`, `model`. Any other key fails onboarding (no implicit credential or env pass-through). +- Allowed `tools` fields: `profile`, `allow`, `deny`. Allowed per-agent `subagents` fields: `delegationMode`, `allowAgents`, `model`, `thinking`, `requireAgentId`. Any other nested key fails onboarding. OpenClaw accepts `subagents.maxSpawnDepth` only on `agents.defaults.subagents`, never inside a per-agent `subagents` object. The value must be an integer between `1` and `5` (OpenClaw's accepted range); to set it, use the object payload shape and pass it under `defaults`: @@ -5232,7 +5240,7 @@ Set the onboarding variables before running `$$nemoclaw onboard` if a slow conne |----------|---------|---------| | `NEMOCLAW_OLLAMA_PULL_TIMEOUT` | `1800` (30 minutes) | Wall-clock timeout for `ollama pull` during onboard, in seconds. Accepts integer or float values. Already-downloaded layers are kept; re-running the pull resumes them. | | `NEMOCLAW_LOCAL_INFERENCE_TIMEOUT` | `180` | Wall-clock timeout for the inference-server validation probe during onboard, in seconds. Raise on slow networks or for very large prompts. | -| `NEMOCLAW_SANDBOX_READY_TIMEOUT` | `180` | Wall-clock timeout for post-create readiness, in seconds. Raise the timeout when the sandbox image build, gateway upload, or in-sandbox boot exceeds the default (typical on 70B+ models, first-time gateway uploads over slow links, or DGX Station / remote-VM first runs). Ordinary onboarding deletes the partially created sandbox when the deadline expires and prints the retry hint. Portable OpenClaw onboarding instead preserves the sandbox when NemoClaw cannot verify its exact runtime identity. | +| `NEMOCLAW_SANDBOX_READY_TIMEOUT` | `180` | Wall-clock timeout for post-create readiness, in seconds. Raise the timeout when the managed-image pull, explicit custom image build, gateway upload, or in-sandbox boot exceeds the default (typical on 70B+ models, first-time gateway uploads over slow links, or DGX Station / remote-VM first runs). Ordinary onboarding deletes the partially created sandbox when the deadline expires and prints the retry hint. Portable OpenClaw onboarding instead preserves the sandbox when NemoClaw cannot verify its exact runtime identity. | | `NEMOCLAW_SANDBOX_READY_ERROR_DEBOUNCE` | `30` | Consecutive `Error`-phase polls the post-create readiness wait tolerates before treating `Error` as terminal. Polling starts at 250ms and backs off to a 2-second cap, while `NEMOCLAW_SANDBOX_READY_TIMEOUT` remains the overall deadline. The gateway can briefly report a just-created sandbox in `Error` while it re-registers the sandbox (seen on DGX Spark); the debounce lets that transient recover to `Ready`. `Failed` and `CrashLoopBackOff` always fail immediately. Set to `1` to restore fast-fail on the first `Error` poll. | | `NEMOCLAW_GATEWAY_RECOVERY_WAIT_SECONDS` | `30`, `90`, or `120`, depending on the recovery phase | Wall-clock timeout for OpenShell command re-registration after policy application, plus gateway health and re-registration during managed OpenClaw or Hermes recovery. A valid finite, nonnegative value overrides the internal budget for the current recovery phase. | diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml index 35f6c480f73..15070a51cff 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox-permissive.yaml @@ -24,6 +24,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index 1d509768db9..7458486c77a 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -27,6 +27,7 @@ filesystem_policy: - /dev/urandom - /app - /run/nemoclaw/managed-startup-ca-bundle.pem + - /run/nemoclaw/managed-startup-runtime.env - /etc - /var/log - /var/lib/dpkg # Allow package-version inspection without package mutation. diff --git a/scripts/checks/build-protected-managed-images.sh b/scripts/checks/build-protected-managed-images.sh index c30ee176c76..1e01e121f75 100755 --- a/scripts/checks/build-protected-managed-images.sh +++ b/scripts/checks/build-protected-managed-images.sh @@ -85,6 +85,7 @@ case "$platform" in linux/amd64) npm_target_cpu="x64" ;; linux/arm64) npm_target_cpu="arm64" ;; esac +target_arch="${platform#linux/}" npm_target_os="linux" npm_target_libc="glibc" [[ "$openclaw_base" =~ ^ghcr[.]io/nvidia/nemoclaw/sandbox-base@sha256:[a-f0-9]{64}$ ]] || usage @@ -376,7 +377,8 @@ build_agent() { -f "$dockerfile_path" \ --build-arg "BASE_IMAGE=${base_reference}" \ --build-arg "NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1" \ - --build-arg "NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root" + --build-arg "NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root" \ + --build-arg "TARGETARCH=${target_arch}" local -a build_command=(docker buildx build --file "$dockerfile_path" @@ -398,6 +400,7 @@ build_agent() { --build-arg "BASE_IMAGE=${base_reference}" --build-arg "NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=1" --build-arg "NEMOCLAW_MANAGED_IMAGE_RUNTIME_USER=root" + --build-arg "TARGETARCH=${target_arch}" "$source_root") run_build_with_retry "$agent" "$image_repository" "${build_command[@]}" diff --git a/scripts/checks/run-managed-image-direct-e2e.ts b/scripts/checks/run-managed-image-direct-e2e.ts index 74928135b00..7678c857b11 100755 --- a/scripts/checks/run-managed-image-direct-e2e.ts +++ b/scripts/checks/run-managed-image-direct-e2e.ts @@ -629,6 +629,14 @@ export function runManagedImageDirectE2e(input: ManagedImageDirectE2eInputs): vo "cat", "/usr/local/share/nemoclaw/corporate-ca.pem", ]).stdout; + const installedSystemCaAnchor = docker([ + "exec", + "--user", + "0:0", + containerId, + "cat", + "/usr/local/share/ca-certificates/nemoclaw-corporate-ca-01.crt", + ]).stdout; const mergedCa = docker([ "exec", "--user", @@ -639,6 +647,7 @@ export function runManagedImageDirectE2e(input: ManagedImageDirectE2eInputs): vo ]).stdout; if ( installedCa !== MANAGED_STARTUP_E2E_CORPORATE_CA_PEM || + installedSystemCaAnchor !== MANAGED_STARTUP_E2E_CORPORATE_CA_PEM || !mergedCa.endsWith(MANAGED_STARTUP_E2E_CORPORATE_CA_PEM) ) { throw new Error("managed corporate CA was not installed and merged exactly"); @@ -655,6 +664,8 @@ export function runManagedImageDirectE2e(input: ManagedImageDirectE2eInputs): vo 'test "$(stat -c "%u:%g:%a" /run/nemoclaw/managed-startup-runtime.env)" = "0:0:444"', 'test "$(stat -c "%u:%g:%a" /run/nemoclaw/managed-startup-complete.json)" = "0:0:444"', 'test "$(stat -c "%u:%g:%a" /usr/local/share/nemoclaw/corporate-ca.pem)" = "0:0:444"', + 'test "$(stat -c "%u:%g:%a" /usr/local/share/ca-certificates/nemoclaw-corporate-ca-01.crt)" = "0:0:444"', + "openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /usr/local/share/nemoclaw/corporate-ca.pem >/dev/null", 'test "$(stat -c "%u:%g:%a" /run/nemoclaw/managed-startup-ca-bundle.pem)" = "0:0:444"', "test -d /var/lib/nemoclaw/managed-startup-shared-state-transaction-v1", ].join("\n"), diff --git a/scripts/checks/run-managed-image-openshell-e2e.ts b/scripts/checks/run-managed-image-openshell-e2e.ts index 50b6945ece7..361b328fe53 100644 --- a/scripts/checks/run-managed-image-openshell-e2e.ts +++ b/scripts/checks/run-managed-image-openshell-e2e.ts @@ -463,6 +463,18 @@ export function managedImageOpenShellProbe( "corporate CA owner, group, and mode must equal 0:0:444", 'test "$(stat -c "%u:%g:%a" /usr/local/share/nemoclaw/corporate-ca.pem)" = "0:0:444"', ), + probeStep( + "corporate CA system anchor must match the managed material", + "cmp -s /usr/local/share/nemoclaw/corporate-ca.pem /usr/local/share/ca-certificates/nemoclaw-corporate-ca-01.crt", + ), + probeStep( + "corporate CA system anchor owner, group, and mode must equal 0:0:444", + 'test "$(stat -c "%u:%g:%a" /usr/local/share/ca-certificates/nemoclaw-corporate-ca-01.crt)" = "0:0:444"', + ), + probeStep( + "system trust must verify the managed corporate CA", + "openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /usr/local/share/nemoclaw/corporate-ca.pem >/dev/null", + ), probeStep( "managed startup CA bundle must exist and be nonempty", "test -s /run/nemoclaw/managed-startup-ca-bundle.pem", diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 4131af277db..1f0b7f95f44 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -4807,6 +4807,15 @@ start_plugin_registry_refresh() { sh -c "exec \"\$@\" >\"\$PLUGIN_REFRESH_LOG\" 2>&1" sh \ "$OPENCLAW" plugins registry --refresh || true fi + + # The registry refresh may rewrite openclaw.json after the gateway reports + # ready. Keep the mutable integrity metadata ordered after that writer so a + # rebuild cannot observe the refreshed config with its previous hash. Run + # this even when the best-effort refresh fails because it may have written + # part of the config before returning nonzero. + if ! ensure_mutable_openclaw_config_hash; then + echo "[plugin-refresh] mutable OpenClaw config hash refresh failed" >&2 + fi ) & PLUGIN_REFRESH_PID=$! if ! capture_openclaw_pid_start_identity "$PLUGIN_REFRESH_PID" PLUGIN_REFRESH_PID_START_IDENTITY; then diff --git a/scripts/runtime-state-mutation-control.py b/scripts/runtime-state-mutation-control.py index 9b519807c2e..7d0015e8a8a 100755 --- a/scripts/runtime-state-mutation-control.py +++ b/scripts/runtime-state-mutation-control.py @@ -2543,6 +2543,18 @@ def _stop_reference(reference: ProcessReference) -> None: _wait_for_reference_state(reference, ("T", "t")) +def _wait_for_host_stopped_supervisor(reference: ProcessReference) -> ProcessIdentity: + deadline = time.monotonic() + PROCESS_STATE_SECONDS + while True: + process = _recapture_reference(reference, "supervisor-identity-drift") + if process.state in ("T", "t"): + return process + remaining = deadline - time.monotonic() + if remaining <= 0: + _fail("supervisor-not-host-stopped") + time.sleep(min(POLL_SECONDS, remaining)) + + def _allowed_writer_map( allowed: tuple[ProcessReference, ...], ) -> dict[int, ProcessReference]: @@ -2663,7 +2675,12 @@ def _hold_exact_processes( activation: ActivationProof | None, ) -> None: _prove_fence_shape(fence, expected_mount_namespace) - _stop_reference(fence.supervisor) + # PID-namespace init accepts SIGSTOP only from an ancestor PID namespace. + # The provider must therefore stop the exact persisted runtime through its + # host-side engine authority before invoking this root helper. Keep the + # helper responsible for proving that boundary and for fencing every + # workload writer inside the already-proven private namespace. + _wait_for_host_stopped_supervisor(fence.supervisor) _stop_reference(fence.start) if activation is not None: persistent = set(activation.persistent_pids) @@ -3671,7 +3688,7 @@ def _retire_activation_tree( ) -> None: fence = _fence_from_value(marker["fence"]) _prove_fence_shape(fence, str(marker["mountNamespace"])) - _stop_reference(fence.supervisor) + _wait_for_host_stopped_supervisor(fence.supervisor) _stop_reference(fence.start) for reference in activation.processes: if not _reference_is_terminated(reference): @@ -3814,23 +3831,15 @@ def _release_activation_hold(durable_fd: int, marker: dict[str, object]) -> None _fail("activation-marker-invalid") _verify_activation_checkpoint(marker, fence, activation) _publish_activation_release(durable_fd, marker, fence, activation) - supervisor, _start = _prove_fence_shape(fence, str(marker["mountNamespace"])) - if supervisor.state in ("T", "t"): - persistent = set(activation.persistent_pids) - for reference in activation.processes: - if _reference_is_terminated(reference): - if reference.pid in persistent: - _fail("activation-process-drift") - continue - _resume_reference(reference) - _resume_reference(fence.start) - _prove_released_activation(marker, fence, activation) - supervisor = _recapture_reference(fence.supervisor, "supervisor-identity-drift") - if supervisor.state not in ("T", "t"): - _fail("release-order-ambiguous") - _signal_exact_process(supervisor, signal.SIGCONT) - _wait_for_reference_running(fence.supervisor) - return + _prove_fence_shape(fence, str(marker["mountNamespace"])) + persistent = set(activation.persistent_pids) + for reference in activation.processes: + if _reference_is_terminated(reference): + if reference.pid in persistent: + _fail("activation-process-drift") + continue + _resume_reference(reference) + _resume_reference(fence.start) _prove_released_activation(marker, fence, activation) diff --git a/scripts/runtime_state_mutation_hermes_publisher.py b/scripts/runtime_state_mutation_hermes_publisher.py index bc0a3237dcf..f2dba46eee2 100755 --- a/scripts/runtime_state_mutation_hermes_publisher.py +++ b/scripts/runtime_state_mutation_hermes_publisher.py @@ -56,6 +56,7 @@ MAX_GUARD_OUTPUT_BYTES = 16 * 1024 GUARD_TIMEOUT_SECONDS = 13 * 60 HEX_64 = re.compile(r"[0-9a-f]{64}\Z") +POSITIVE_DECIMAL = re.compile(r"[1-9][0-9]*\Z") BEGIN_OUTPUT = re.compile(r"lock_token=([0-9a-f]{64}) original_locked=([01])\n?\Z") PHASES = frozenset( { @@ -127,6 +128,12 @@ def _hex(value: object, code: str) -> str: return value +def _positive_decimal(value: object, code: str) -> str: + if not isinstance(value, str) or POSITIVE_DECIMAL.fullmatch(value) is None: + _fail(code) + return value + + def _safe_component(value: object, code: str) -> str: if ( not isinstance(value, str) @@ -283,6 +290,12 @@ def _normalize_marker(marker: object, posture: str) -> dict[str, object]: nonce = _hex(marker.get("nonce"), "publisher-marker-invalid") plan_sha256 = _hex(marker.get("planSha256"), "publisher-marker-invalid") projection_sha256 = _hex(marker.get("projectionSha256"), "publisher-marker-invalid") + state_root_device = _positive_decimal( + marker.get("stateRootDevice"), "publisher-marker-invalid" + ) + state_root_inode = _positive_decimal( + marker.get("stateRootInode"), "publisher-marker-invalid" + ) provider_id = marker.get("providerId") if ( not isinstance(provider_id, str) @@ -383,6 +396,8 @@ def _normalize_marker(marker: object, posture: str) -> dict[str, object]: "nonce": nonce, "planSha256": plan_sha256, "projectionSha256": projection_sha256, + "stateRootDevice": state_root_device, + "stateRootInode": state_root_inode, "target": target, "rollback": rollback, "plan": plan_text, @@ -395,6 +410,8 @@ def _normalize_marker(marker: object, posture: str) -> dict[str, object]: "nonce": nonce, "planSha256": plan_sha256, "projectionSha256": projection_sha256, + "stateRootDevice": state_root_device, + "stateRootInode": state_root_inode, "target": target, "rollback": rollback, "posture": posture, @@ -673,7 +690,12 @@ def _guard_arguments(*values: str) -> list[str]: return list(values) -def _begin_guard(posture: str, rollback_posture: str) -> str: +def _begin_guard( + posture: str, + rollback_posture: str, + state_root_device: str, + state_root_inode: str, +) -> str: output = _run_guard( "begin-shields-transition", _guard_arguments( @@ -685,6 +707,10 @@ def _begin_guard(posture: str, rollback_posture: str) -> str: posture, "--rollback-shields-mode", rollback_posture, + "--expected-hermes-device", + state_root_device, + "--expected-hermes-inode", + state_root_inode, ), ) matched = BEGIN_OUTPUT.fullmatch(output) @@ -981,7 +1007,12 @@ def _continue_forward( guard_state = _matching_guard_token(directory_fd, posture, rollback_posture) if phase == "intent": if guard_state is None: - token = _begin_guard(posture, rollback_posture) + token = _begin_guard( + posture, + rollback_posture, + str(normalized["stateRootDevice"]), + str(normalized["stateRootInode"]), + ) else: token, guard_phase = guard_state if guard_phase not in ( diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index 6e5517f12d1..9e35ca53701 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -59,6 +59,18 @@ describe("onboard oclif command", () => { ); }); + it("accepts an exact managed runtime catalog without candidate activation", async () => { + await OnboardCliCommand.run( + ["--temp-managed-runtime-catalog", "managed-catalog.json"], + rootDir, + ); + + const [flags, deps] = vi.mocked(runOnboardAction).mock.calls[0]!; + expect(flags["temp-managed-runtime-catalog"]).toBe("managed-catalog.json"); + expect(flags["temp-managed-runtime"]).toBeUndefined(); + expect(deps).toBe(mocks.onboardRuntimeDeps); + }); + it("forwards typed sandbox GPU flags", async () => { await OnboardCliCommand.run( ["--non-interactive", "--yes", "--sandbox-gpu", "--sandbox-gpu-device", "nvidia.com/gpu=0"], diff --git a/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts b/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts index d5fddff9cea..f57fecfa009 100644 --- a/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts +++ b/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts @@ -72,6 +72,32 @@ describe("ordinary OpenClaw pairing target", () => { }); }); + it("resolves a custom Dockerfile without inventing a managed agent version", () => { + vi.mocked(deps.getSandbox!).mockReturnValue({ + ...openClawEntry(), + agentVersion: null, + nemoclawVersion: null, + fromDockerfile: "/tmp/custom-openclaw/Dockerfile", + }); + + expect(resolveOrdinaryOpenClawPairingTarget(SANDBOX_NAME, deps)).toEqual({ + gatewayName: GATEWAY_NAME, + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: FINGERPRINT, + stateDirectory: "/sandbox/.openclaw", + version: "", + }); + }); + + it("rejects a managed workload whose agent version is missing", () => { + vi.mocked(deps.getSandbox!).mockReturnValue({ + ...openClawEntry(), + agentVersion: null, + }); + + expect(resolveOrdinaryOpenClawPairingTarget(SANDBOX_NAME, deps)).toBeNull(); + }); + it.each([ ["missing agent identity", { agent: undefined }], ["pending route reservation", { pendingRouteReservation: true }], diff --git a/src/lib/actions/sandbox/launch-readiness.ts b/src/lib/actions/sandbox/launch-readiness.ts index ee6c2720ee6..f84b447a656 100644 --- a/src/lib/actions/sandbox/launch-readiness.ts +++ b/src/lib/actions/sandbox/launch-readiness.ts @@ -903,6 +903,7 @@ function resolveOpenClawPairingSettlementTarget( entry: SandboxEntry | null, deps: LaunchReadinessDeps, requiredGeneration?: string, + allowUnknownCustomVersion = false, ): OpenClawPairingSettlementTarget | null { // Policy eligibility belongs to the settlement caller. Ordinary onboarding // permits policy skip, while Portable pairing requires the finalized marker. @@ -927,13 +928,21 @@ function resolveOpenClawPairingSettlementTarget( } catch { return null; } - const version = normalizedString(entry.agentVersion); + const recordedVersion = normalizedString(entry.agentVersion); + // Custom Dockerfile workloads intentionally have no managed agent version: + // registration must not stamp the manifest's version onto unreviewed image + // contents. Ordinary settlement does not use the version to select a + // command shape, so its caller may preserve that unknown value as the empty + // string while retaining the exact registry and live-lifecycle checks. + const customDockerfile = normalizedString(entry.fromDockerfile); + const version = + recordedVersion ?? (allowUnknownCustomVersion && customDockerfile ? "" : null); const expectedVersion = normalizedString(agent.expected_version); const stateDirectory = normalizedString(agent.config?.dir); const lifecycleGeneration = normalizedString(entry.lifecycleGeneration); const lifecycleLiveIdentityFingerprint = normalizedString(entry.lifecycleLiveIdentityFingerprint); if ( - !version || + version === null || !expectedVersion || !stateDirectory || !lifecycleGeneration || @@ -958,7 +967,13 @@ export function resolveOrdinaryOpenClawPairingTarget( ): OpenClawPairingSettlementTarget | null { try { const getSandbox = deps.getSandbox ?? registry.getSandbox; - return resolveOpenClawPairingSettlementTarget(sandboxName, getSandbox(sandboxName), deps); + return resolveOpenClawPairingSettlementTarget( + sandboxName, + getSandbox(sandboxName), + deps, + undefined, + true, + ); } catch { return null; } diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts index cc892b8dc0d..20816107c41 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.test.ts @@ -74,6 +74,24 @@ describe("OpenClaw mcporter MCP adapter", testTimeoutOptions(20_000), () => { expected, ), ).toBe(false); + expect( + mcporterHeadersMatchExpected( + { + Authorization: "Bearer openshell:resolve:env:v42_OTHER_TOKEN", + accept: "application/json, text/event-stream", + }, + expected, + ), + ).toBe(false); + expect( + mcporterHeadersMatchExpected( + { + Authorization: `Bearer openshell:resolve:env:v${"1".repeat(21)}_GITHUB_TOKEN`, + accept: "application/json, text/event-stream", + }, + expected, + ), + ).toBe(false); expect( mcporterHeadersMatchExpected( { diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts index 7edfaab81e3..9103e3d0d5a 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-openclaw.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { shellQuote } from "../../runner"; import type { McpBridgeEntry } from "../../state/registry"; import { type AdapterMutationOptions, @@ -21,6 +20,7 @@ import { import { McpBridgeError } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; import type { McpAttachedCredentialRevision } from "./mcp-bridge-provider-readiness"; +import { quoteMcpBridgeShellArg } from "./mcp-bridge-runtime-command"; import { getAgentConfigDir } from "./mcp-bridge-state"; import { executeSandboxCommand } from "./process-recovery"; @@ -57,14 +57,14 @@ export function buildOpenClawMcporterRegisterCommand( const authorization = authorizationValue(entry, credentialRevision); if (authorization) args.push("--header", `Authorization=${authorization}`); args.push("--scope", "project"); - const addCommand = args.map(shellQuote).join(" "); + const addCommand = args.map(quoteMcpBridgeShellArg).join(" "); if (replaceExisting) return addCommand; const getCommand = mcporterArgs(root, "config", "get", entry.server, "--json") - .map(shellQuote) + .map(quoteMcpBridgeShellArg) .join(" "); return [ `if ${getCommand} >/dev/null 2>&1; then`, - ` echo ${shellQuote(`MCP server '${entry.server}' already exists in mcporter config and is not managed by NemoClaw.`)} >&2`, + ` echo ${quoteMcpBridgeShellArg(`MCP server '${entry.server}' already exists in mcporter config and is not managed by NemoClaw.`)} >&2`, " exit 2", "fi", addCommand, diff --git a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts index 5764e81c2d1..6ed1286cfaa 100644 --- a/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts +++ b/src/lib/actions/sandbox/mcp-bridge-adapter-status.ts @@ -74,23 +74,26 @@ export function mcporterHeadersMatchExpected( } const actualHeaders = actual as Record; for (const [name, value] of Object.entries(expected)) { + if (actualHeaders[name] === value) continue; + const canonicalPrefix = "Bearer openshell:resolve:env:"; + const envName = value.startsWith(canonicalPrefix) ? value.slice(canonicalPrefix.length) : ""; const actualValue = actualHeaders[name]; - if (actualValue === value) continue; - if (name.toLowerCase() !== "authorization") return false; - const prefix = "Bearer openshell:resolve:env:"; if ( - typeof actualValue !== "string" || - !value.startsWith(prefix) || - !actualValue.startsWith(prefix) + name.toLowerCase() !== "authorization" || + !/^[A-Z][A-Z0-9_]{0,127}$/u.test(envName) || + typeof actualValue !== "string" + ) { + return false; + } + const escapedEnvName = envName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + if ( + !new RegExp( + `^${canonicalPrefix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}v[0-9]{1,20}_${escapedEnvName}$`, + "u", + ).test(actualValue) ) { return false; } - const envName = value.slice(prefix.length); - const versioned = actualValue.slice(prefix.length); - const suffix = `_${envName}`; - if (!versioned.startsWith("v") || !versioned.endsWith(suffix)) return false; - const revision = versioned.slice(1, -suffix.length); - if (!/^[0-9]{1,20}$/u.test(revision)) return false; } const extraNames = Object.keys(actualHeaders).filter((name) => !Object.hasOwn(expected, name)); if (extraNames.length === 0) return true; diff --git a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts index beafd68161f..be5528c436b 100644 --- a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.test.ts @@ -8,6 +8,7 @@ import type { McpBridgeEntry, SandboxEntry } from "../../state/registry"; const mocks = vi.hoisted(() => ({ getSandbox: vi.fn(), runOpenshellProviderCommand: vi.fn(), + sleepMs: vi.fn(), })); vi.mock("../../state/registry", () => ({ @@ -18,6 +19,10 @@ vi.mock("../../adapters/openshell/provider-command", () => ({ runOpenshellProviderCommand: mocks.runOpenshellProviderCommand, })); +vi.mock("./mcp-bridge/timing", () => ({ + sleepMcpBridgeRetry: mocks.sleepMs, +})); + import { assertHermesMcpRuntimeIntent, inspectHermesMcpRuntimeIntent, @@ -55,6 +60,7 @@ describe("Hermes MCP host reconciliation", () => { stdout: '{"ok":true,"state":"matched"}\n', stderr: "", }); + mocks.sleepMs.mockReset(); }); afterEach(() => { @@ -144,6 +150,8 @@ describe("Hermes MCP host reconciliation", () => { expect(() => assertHermesMcpRuntimeIntent("alpha")).not.toThrow(); expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledTimes(2); + expect(mocks.sleepMs).toHaveBeenCalledOnce(); + expect(mocks.sleepMs).toHaveBeenCalledWith(500); }); it("bounds raced integrity snapshot retries and still fails closed", () => { @@ -156,7 +164,8 @@ describe("Hermes MCP host reconciliation", () => { expect(() => assertHermesMcpRuntimeIntent("alpha")).toThrow( /refusing raced Hermes MCP integrity snapshot/, ); - expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledTimes(3); + expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledTimes(6); + expect(mocks.sleepMs).toHaveBeenCalledTimes(5); }); it("sanitizes thrown helper failures before returning or throwing them", () => { diff --git a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts index 951f1164072..754d924f0be 100644 --- a/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-hermes-reconciliation.ts @@ -8,6 +8,7 @@ import * as registry from "../../state/registry"; import { buildHermesMcpIntentPayload } from "./mcp-bridge-adapter-status"; import { McpBridgeError } from "./mcp-bridge-contracts"; import { redactBridgeSecretsForDisplay } from "./mcp-bridge-output"; +import { sleepMcpBridgeRetry } from "./mcp-bridge/timing"; const HERMES_MCP_TRANSACTION_HELPER = "/usr/local/lib/nemoclaw/hermes-mcp-config-transaction.py"; const HERMES_MCP_INSPECT_TIMEOUT_SECONDS = 45; @@ -15,7 +16,8 @@ const HERMES_MCP_INSPECT_TIMEOUT_MS = 60_000; const HERMES_MCP_RECONCILIATION_FAILURE = "Hermes MCP runtime does not match the persisted managed intent"; const HERMES_MCP_RACED_SNAPSHOT_DETAIL = "refusing raced Hermes MCP integrity snapshot"; -const HERMES_MCP_RACED_SNAPSHOT_ATTEMPTS = 3; +const HERMES_MCP_RACED_SNAPSHOT_ATTEMPTS = 6; +const HERMES_MCP_RACED_SNAPSHOT_RETRY_MS = 500; const ANSI_OR_UNSAFE_CONTROL_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g; const DISPLAY_LINE_BREAK_RE = /[\r\n\u2028\u2029]+/g; @@ -197,6 +199,7 @@ export function assertHermesMcpRuntimeIntent( attempt < HERMES_MCP_RACED_SNAPSHOT_ATTEMPTS; attempt += 1 ) { + sleepMcpBridgeRetry(HERMES_MCP_RACED_SNAPSHOT_RETRY_MS); inspection = inspectHermesMcpRuntimeIntent(sandboxName, options); } if (inspection.ok) return; diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts index c7ba8c52dca..6663c8b8ee9 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-mutation.ts @@ -15,9 +15,12 @@ * targets. */ -import path from "node:path"; - import { runOpenshellProviderCommand } from "../../adapters/openshell/provider-command"; +import { REPOSITORY_ROOT } from "../../core/repository-root"; +import { + endpointlessProviderProfilePath, + ensureEndpointlessProviderProfile, +} from "../../messaging/provider-profile"; import type { McpBridgeEntry } from "../../state/registry"; import { McpBridgeError, type ParsedEnvReference } from "./mcp-bridge-contracts"; import { commandOutput, type OpenShellCommandResult } from "./mcp-bridge-output"; @@ -46,27 +49,6 @@ export { const OPENAI_GATEWAY_PROVIDER_TYPE = "openai"; -function profileHasExpectedCredentialBoundary( - output: string, - expected: { id: string; inferenceCapable: boolean }, -): boolean { - try { - const parsed = JSON.parse(output) as Record; - return ( - parsed.id === expected.id && - Array.isArray(parsed.credentials) && - parsed.credentials.length === 0 && - Array.isArray(parsed.endpoints) && - parsed.endpoints.length === 0 && - Array.isArray(parsed.binaries) && - parsed.binaries.length === 0 && - parsed.inference_capable === expected.inferenceCapable - ); - } catch { - return false; - } -} - /** * OpenShell 0.0.106 still accepts the legacy `openai` provider type without a * declarative profile. Its static-credential resolver then emits the provider @@ -74,99 +56,62 @@ function profileHasExpectedCredentialBoundary( * provider environment as unclassified when an MCP provider is attached. * Registering an endpointless profile makes the gateway-only inference key * explicitly non-injectable while preserving OpenShell's inference route. + * + * invalidState: an unprofiled gateway-only inference credential revokes the + * otherwise valid endpoint-bound MCP credential snapshot. + * sourceBoundary: OpenShell owns provider-environment classification and + * rejects mixed snapshots atomically. + * whyNotSourceFix: NemoClaw must remain compatible with the pinned OpenShell + * 0.0.106 runtime, so it declares the missing profile contract before attach. + * regressionTest: mcp-bridge-provider-profile.test.ts proves exact existing + * profile validation and rejects credential, endpoint, and malformed drift. + * removalCondition: remove this import when the minimum supported OpenShell + * release classifies the `openai` inference credential as gateway-only itself. */ function ensureOpenAiGatewayProviderProfile(): void { - const profilePath = path.resolve( - __dirname, - "../../../..", - "nemoclaw-blueprint", - "provider-profiles", - "openai.yaml", - ); - const imported = runOpenshellProviderCommand( - ["provider", "profile", "import", "--file", profilePath], - { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }, - ) as OpenShellCommandResult; - if (imported.status === 0) return; - - const importOutput = commandOutput(imported); - if (!/already exists/i.test(importOutput)) { + const result = ensureEndpointlessProviderProfile({ + profileId: OPENAI_GATEWAY_PROVIDER_TYPE, + inferenceCapable: true, + profilePath: endpointlessProviderProfilePath(REPOSITORY_ROOT, OPENAI_GATEWAY_PROVIDER_TYPE), + runOpenshell: (args, options) => + runOpenshellProviderCommand(args, options) as OpenShellCommandResult, + }); + if (result.ok) return; + if (result.reason === "import-failed") { throw new McpBridgeError( - importOutput || "Could not import the OpenShell OpenAI gateway provider profile.", + result.diagnostic || "Could not import the OpenShell OpenAI gateway provider profile.", ); } - - const exported = runOpenshellProviderCommand( - ["provider", "profile", "export", OPENAI_GATEWAY_PROVIDER_TYPE, "--output", "json"], - { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }, - ) as OpenShellCommandResult; - if (exported.status !== 0) { + if (result.reason === "export-failed") { throw new McpBridgeError( `OpenShell provider profile '${OPENAI_GATEWAY_PROVIDER_TYPE}' already exists but could not be exported for validation. Refusing to classify gateway inference credentials with it.`, ); } - if ( - !profileHasExpectedCredentialBoundary(String(exported.stdout), { - id: OPENAI_GATEWAY_PROVIDER_TYPE, - inferenceCapable: true, - }) - ) { - throw new McpBridgeError( - `OpenShell provider profile '${OPENAI_GATEWAY_PROVIDER_TYPE}' already exists but does not match NemoClaw's gateway-only endpointless credential contract. Refusing to classify gateway inference credentials with it.`, - ); - } + throw new McpBridgeError( + `OpenShell provider profile '${OPENAI_GATEWAY_PROVIDER_TYPE}' already exists but does not match NemoClaw's gateway-only endpointless credential contract. Refusing to classify gateway inference credentials with it.`, + ); } /** Ensure the endpointless profile required by OpenShell static credential binding. */ export function ensureMcpBridgeProviderProfile(): void { ensureOpenAiGatewayProviderProfile(); - const profilePath = path.resolve( - __dirname, - "../../../..", - "nemoclaw-blueprint", - "provider-profiles", - `${MCP_BRIDGE_PROVIDER_TYPE}.yaml`, - ); - const imported = runOpenshellProviderCommand( - ["provider", "profile", "import", "--file", profilePath], - { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }, - ) as OpenShellCommandResult; - if (imported.status === 0) return; - - const importOutput = commandOutput(imported); - if (!/already exists/i.test(importOutput)) { - throw new McpBridgeError( - importOutput || `Could not import OpenShell provider profile '${MCP_BRIDGE_PROVIDER_TYPE}'.`, - ); - } - - const exported = runOpenshellProviderCommand( - ["provider", "profile", "export", MCP_BRIDGE_PROVIDER_TYPE, "--output", "json"], - { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }, - ) as OpenShellCommandResult; - if ( - exported.status !== 0 || - !profileHasExpectedCredentialBoundary(String(exported.stdout), { - id: MCP_BRIDGE_PROVIDER_TYPE, - inferenceCapable: false, - }) - ) { + const result = ensureEndpointlessProviderProfile({ + profileId: MCP_BRIDGE_PROVIDER_TYPE, + inferenceCapable: false, + profilePath: endpointlessProviderProfilePath(REPOSITORY_ROOT, MCP_BRIDGE_PROVIDER_TYPE), + runOpenshell: (args, options) => + runOpenshellProviderCommand(args, options) as OpenShellCommandResult, + }); + if (result.ok) return; + if (result.reason === "import-failed") { throw new McpBridgeError( - `OpenShell provider profile '${MCP_BRIDGE_PROVIDER_TYPE}' already exists but does not match NemoClaw's endpointless credential contract. Refusing to attach MCP credentials to it.`, + result.diagnostic || + `Could not import OpenShell provider profile '${MCP_BRIDGE_PROVIDER_TYPE}'.`, ); } + throw new McpBridgeError( + `OpenShell provider profile '${MCP_BRIDGE_PROVIDER_TYPE}' already exists but does not match NemoClaw's endpointless credential contract. Refusing to attach MCP credentials to it.`, + ); } export function buildMcpBridgeProviderArgs( diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts b/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts index 22510bb1d1c..a7ad8d4dd6c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-readiness.ts @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { waitUntil } from "../../core/wait"; import { shellQuote } from "../../runner"; import type { McpBridgeEntry } from "../../state/registry"; import { McpBridgeError } from "./mcp-bridge-contracts"; +import { waitForMcpBridgeCondition } from "./mcp-bridge/timing"; import { assertAuthenticatedBridgeEntry, assertPersistedAuthenticatedBridgeEntry, @@ -163,7 +163,7 @@ export function waitForAttachedMcpCredential( let refreshedAfterObservedAbsence = false; let lastAttempt: McpCredentialRevisionAttempt = { kind: "transport-unavailable" }; let attachedRevision: McpAttachedCredentialRevision | undefined; - const ready = waitUntil( + const ready = waitForMcpBridgeCondition( () => { // Each exec is a fresh OpenShell process. Only the bounded placeholder // classification crosses back to the host, where the comparison cannot @@ -225,7 +225,7 @@ export function waitForDetachedMcpCredential(sandboxName: string, entry: McpBrid process.env.NEMOCLAW_MCP_PROVIDER_SYNC_TIMEOUT_SECONDS ?? "30", 10, ); - const revoked = waitUntil( + const revoked = waitForMcpBridgeCondition( () => executeMcpCredentialProofCommand(sandboxName, buildMcpCredentialDetachedCommand(envName)) ?.status === 0, diff --git a/src/lib/actions/sandbox/mcp-bridge-runtime-command.ts b/src/lib/actions/sandbox/mcp-bridge-runtime-command.ts index 85a35cdb764..dc1067d6374 100644 --- a/src/lib/actions/sandbox/mcp-bridge-runtime-command.ts +++ b/src/lib/actions/sandbox/mcp-bridge-runtime-command.ts @@ -4,6 +4,9 @@ import type { AgentMcpAdapter } from "../../agent/defs"; import { shellQuote } from "../../core/shell-quote"; +/** Quote one argument for an MCP bridge-owned shell command. */ +export const quoteMcpBridgeShellArg = shellQuote; + /** * Process-control variables that must not reach a credential-bearing child * diagnostic. Trusted proxy and CA variables remain available; OpenShell diff --git a/src/lib/actions/sandbox/mcp-bridge/timing.ts b/src/lib/actions/sandbox/mcp-bridge/timing.ts new file mode 100644 index 00000000000..29aeb3d546d --- /dev/null +++ b/src/lib/actions/sandbox/mcp-bridge/timing.ts @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { sleepMs, waitUntil } from "../../../core/wait"; + +/** Keep MCP synchronization delays behind one domain-owned timing boundary. */ +export const sleepMcpBridgeRetry = sleepMs; +export const waitForMcpBridgeCondition = waitUntil; diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index d1038b387c8..399dec08536 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -33,6 +33,7 @@ import { MessagingHostStateApplier, MessagingSetupApplier, MessagingWorkflowPlanner, + MESSAGING_CREDENTIAL_PROVIDER_TYPE, runMessagingHook, type SandboxMessagingChannelPlan, type SandboxMessagingPlan, @@ -854,7 +855,7 @@ async function applyChannelAddToGatewayAndRegistry( name: bridgeProviderName(sandboxName, channelName, envKey), envKey, token, - ...(staticProviderType ? { providerType: staticProviderType } : {}), + providerType: staticProviderType ?? MESSAGING_CREDENTIAL_PROVIDER_TYPE, })); // Bridge channels declare no manifest credentials, so the loop above yields // nothing for them. Their provider must be created HERE (same seam onboarding @@ -1525,6 +1526,7 @@ async function rollbackChannelAdd( name: bridgeProviderName(sandboxName, canonical, envKey), envKey, token, + providerType: MESSAGING_CREDENTIAL_PROVIDER_TYPE, })); policyChannelDependencies.upsertMessagingProviders(priorTokenDefs, { bestEffort: true, diff --git a/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts b/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts index 2d3fb270307..9fadcf83056 100644 --- a/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-recovery.test.ts @@ -502,9 +502,14 @@ describe("rebuildSandbox flow: recovery", () => { const mcpEntry = { server: "github", providerName: "nemoclaw-mcp-alpha-github", + policyName: "mcp-bridge-github", }; const harness = createRebuildFlowHarness({ defaultSandbox: "alpha", + sandboxEntry: { + policies: ["npm", "mcp-bridge-github"], + policyPresetsFinalized: true, + }, mcpPreparation: { entries: [mcpEntry], detachedProviderEntries: [mcpEntry], @@ -520,7 +525,7 @@ describe("rebuildSandbox flow: recovery", () => { expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); expect(harness.restoreSandboxEntrySpy.mock.calls).toEqual([ - [expect.objectContaining({ name: "alpha" })], + [expect.objectContaining({ name: "alpha", policies: ["npm", "mcp-bridge-github"] })], ]); }); diff --git a/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts b/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts index cec4e673e20..245cb17bb38 100644 --- a/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts +++ b/src/lib/actions/sandbox/snapshot-managed-clone-providers.test.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash } from "node:crypto"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { managedStartupE2eProfile } from "../../../../scripts/checks/generate-managed-startup-profile-fixture.mts"; +import { REPOSITORY_ROOT } from "../../core/repository-root"; import type { SandboxMessagingPlan } from "../../messaging/manifest"; import { encodeManagedStartupProfile, @@ -157,10 +159,14 @@ function providerRunner(initial: readonly LiveBinding[] = []) { let createBehavior: | ((binding: LiveBinding) => { readonly materialize?: LiveBinding; readonly status: number }) | undefined; + let profileImportResult = { status: 0, stdout: "", stderr: "" }; + let profileExportResult = { status: 0, stdout: "", stderr: "" }; let failDelete = false; const run = vi.fn((args: string[]) => { commands.push(args.join(" ")); switch (args.slice(0, 2).join(" ")) { + case "provider profile": + return args[2] === "import" ? profileImportResult : profileExportResult; case "provider get": { const name = args[2] ?? ""; const binding = live.get(name); @@ -202,6 +208,12 @@ function providerRunner(initial: readonly LiveBinding[] = []) { setFailDelete(value: boolean) { failDelete = value; }, + setProfileImportResult(value: typeof profileImportResult) { + profileImportResult = value; + }, + setProfileExportResult(value: typeof profileExportResult) { + profileExportResult = value; + }, }; } @@ -279,7 +291,7 @@ describe("managed clone provider transaction", () => { { binding: { providerName: "destination-telegram-bridge", - providerType: "generic", + providerType: "nemoclaw-mcp-v1", providerEnvKey: "TELEGRAM_BOT_TOKEN", source: "messaging", }, @@ -288,6 +300,95 @@ describe("managed clone provider transaction", () => { ]); }); + it("imports the endpointless profile before creating a cloned messaging provider (#9875)", () => { + const profile = managedStartupE2eProfile("openclaw"); + const source = entry("source", profile); + const runner = providerRunner(); + const prepared = prepareManagedCloneProviderTransaction({ + handoff: handoff(profile, source, messagingPlan("destination")), + destination: null, + environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, + runOpenshell: runner.run, + transactionId: "9".repeat(32), + }); + + provisionManagedCloneProviderTransaction(prepared, { + ...authorityDeps(source), + environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, + runOpenshell: runner.run, + }); + + const importIndex = runner.commands.findIndex((command) => + command.startsWith("provider profile import --file "), + ); + const createIndex = runner.commands.findIndex((command) => + command.startsWith("provider create --name destination-telegram-bridge "), + ); + expect(importIndex).toBeGreaterThanOrEqual(0); + expect(runner.commands[importIndex]).toBe( + `provider profile import --file ${path.join( + REPOSITORY_ROOT, + "nemoclaw-blueprint", + "provider-profiles", + "nemoclaw-mcp-v1.yaml", + )}`, + ); + expect(createIndex).toBeGreaterThan(importIndex); + }); + + it("rejects stale clone authority before importing the messaging profile (#9875)", () => { + const profile = managedStartupE2eProfile("openclaw"); + const source = entry("source", profile); + const runner = providerRunner(); + const prepared = prepareManagedCloneProviderTransaction({ + handoff: handoff(profile, source, messagingPlan("destination")), + destination: null, + environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, + runOpenshell: runner.run, + transactionId: "8".repeat(32), + }); + + expect(() => + provisionManagedCloneProviderTransaction(prepared, { + ...authorityDeps(source, null, { + ...CONTENT_AUTHORITY, + contentSha256: "d".repeat(64), + }), + environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, + runOpenshell: runner.run, + }), + ).toThrow(/snapshot content changed before mutation/u); + expect( + runner.commands.some((command) => command.startsWith("provider profile import --file ")), + ).toBe(false); + expect(runner.commands.some((command) => command.startsWith("provider create --name "))).toBe( + false, + ); + }); + + it("does not create a cloned messaging provider after profile import fails (#9875)", () => { + const profile = managedStartupE2eProfile("openclaw"); + const source = entry("source", profile); + const runner = providerRunner(); + runner.setProfileImportResult({ status: 1, stdout: "", stderr: "gateway unavailable" }); + const prepared = prepareManagedCloneProviderTransaction({ + handoff: handoff(profile, source, messagingPlan("destination")), + destination: null, + environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, + runOpenshell: runner.run, + transactionId: "7".repeat(32), + }); + + expect(() => + provisionManagedCloneProviderTransaction(prepared, { + ...authorityDeps(source), + environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, + runOpenshell: runner.run, + }), + ).toThrow(/Could not import the OpenShell messaging credential profile/); + expect(runner.commands.some((command) => command.startsWith("provider create"))).toBe(false); + }); + it("reuses an exact provider only with exact destination registry ownership", () => { const profile = managedStartupE2eProfile("openclaw"); const source = entry("source", profile); @@ -295,7 +396,7 @@ describe("managed clone provider transaction", () => { const destination = entry("destination", profile, { messaging: { schemaVersion: 1, plan } }); const liveBinding = { providerName: "destination-telegram-bridge", - providerType: "generic", + providerType: "nemoclaw-mcp-v1", providerEnvKey: "TELEGRAM_BOT_TOKEN", }; const runner = providerRunner([liveBinding]); @@ -323,6 +424,49 @@ describe("managed clone provider transaction", () => { }); }); + it("rejects clone reuse backed by an incompatible global messaging profile (#9875)", () => { + const profile = managedStartupE2eProfile("openclaw"); + const source = entry("source", profile); + const plan = messagingPlan("destination"); + const destination = entry("destination", profile, { messaging: { schemaVersion: 1, plan } }); + const liveBinding = { + providerName: "destination-telegram-bridge", + providerType: "nemoclaw-mcp-v1", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + }; + const runner = providerRunner([liveBinding]); + runner.setProfileImportResult({ status: 1, stdout: "", stderr: "profile already exists" }); + runner.setProfileExportResult({ + status: 0, + stdout: JSON.stringify({ + id: "nemoclaw-mcp-v1", + credentials: [], + endpoints: ["https://foreign.invalid"], + binaries: [], + inference_capable: false, + }), + stderr: "", + }); + const prepared = prepareManagedCloneProviderTransaction({ + handoff: handoff(profile, source, plan), + destination, + environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, + runOpenshell: runner.run, + transactionId: "4".repeat(32), + }); + + expect(() => + provisionManagedCloneProviderTransaction(prepared, { + ...authorityDeps(source, destination), + environment: { TELEGRAM_BOT_TOKEN: "test-only-telegram-token" }, + runOpenshell: runner.run, + }), + ).toThrow(/does not match NemoClaw's endpointless messaging credential contract/u); + expect( + runner.commands.some((command) => /provider (create|delete|update)/u.test(command)), + ).toBe(false); + }); + it("rejects an exact same-name provider without destination ownership", () => { const runner = providerRunner([TOKEN_BINDING]); diff --git a/src/lib/actions/sandbox/snapshot/managed-clone-providers.ts b/src/lib/actions/sandbox/snapshot/managed-clone-providers.ts index 71fd0866b4d..66ec75ee2ca 100644 --- a/src/lib/actions/sandbox/snapshot/managed-clone-providers.ts +++ b/src/lib/actions/sandbox/snapshot/managed-clone-providers.ts @@ -5,7 +5,12 @@ import { randomBytes } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import { cloneAndDeepFreeze } from "../../../core/immutable"; +import { REPOSITORY_ROOT } from "../../../core/repository-root"; import type { SandboxMessagingPlan } from "../../../messaging/manifest"; +import { + ensureMessagingCredentialProviderProfile, + MESSAGING_CREDENTIAL_PROVIDER_TYPE, +} from "../../../messaging/provider-profile"; import { isValidName, isValidProviderName } from "../../../name-validation"; import { reportsExactProviderNotFound } from "../../../onboard/extra-provider-diagnostic-parser"; import { @@ -262,7 +267,7 @@ function applicationBindings(input: { input.profile.agent, ).map((binding) => ({ providerName: binding.providerName, - providerType: "generic", + providerType: MESSAGING_CREDENTIAL_PROVIDER_TYPE, providerEnvKey: binding.providerEnvKey, source: "messaging", })); @@ -293,7 +298,7 @@ function destinationOwnedBindings(entry: SandboxEntry): readonly ManagedClonePro entry.agent, ).map((binding) => ({ providerName: binding.providerName, - providerType: "generic", + providerType: MESSAGING_CREDENTIAL_PROVIDER_TYPE, providerEnvKey: binding.providerEnvKey, source: "messaging", })); @@ -526,10 +531,18 @@ export function provisionManagedCloneProviderTransaction( const environment = input.environment ?? process.env; const confirmed: ManagedCloneProviderOwnershipReceipt[] = []; try { - // The transaction boundary must still fence a clone with no credential - // providers (for example DCode) before a later caller proceeds to sandbox - // or filesystem mutation. + // Fence every shared gateway mutation, including provider profile import. revalidateManagedCloneMutationAuthority(prepared, input); + if ( + prepared.providers.some( + (provider) => provider.binding.providerType === MESSAGING_CREDENTIAL_PROVIDER_TYPE, + ) + ) { + ensureMessagingCredentialProviderProfile({ + root: REPOSITORY_ROOT, + runOpenshell: input.runOpenshell, + }); + } for (const provider of prepared.providers) { revalidateManagedCloneMutationAuthority(prepared, input); const current = inspectProvider(provider.binding, input.runOpenshell); diff --git a/src/lib/actions/sandbox/snapshot/managed-profile.test.ts b/src/lib/actions/sandbox/snapshot/managed-profile.test.ts index 29596cb5275..751ad59a7b9 100644 --- a/src/lib/actions/sandbox/snapshot/managed-profile.test.ts +++ b/src/lib/actions/sandbox/snapshot/managed-profile.test.ts @@ -147,16 +147,29 @@ describe("managed snapshot profile restore", () => { ).toThrow(/invalid managed workload authority/u); }); - it("rejects target profile drift and provider refusal", () => { + it("rebinds a same-name rebuild to its accepted replacement profile", () => { const receipt = workload("openclaw"); const source = { sandboxName: "alpha", agentType: "openclaw", workload: receipt }; - expect(() => - prepareManagedSnapshotProfileRestore( - source, - sandbox("openclaw", workload("openclaw", true)), - provider(), + const replacement = workload("openclaw", true); + + const plan = prepareManagedSnapshotProfileRestore( + source, + sandbox("openclaw", replacement), + provider(), + ); + + expect(plan?.authority.receipt).toEqual(receipt); + expect(plan?.providerRestoreAuthority).toEqual({ + agent: "openclaw", + profileFingerprint: fingerprintManagedStartupProfile( + managedStartupE2eProfile("openclaw", true), ), - ).toThrow(/requires a managed image or startup-profile rebind/u); + }); + }); + + it("rejects provider refusal and cross-sandbox or cross-agent rebind", () => { + const receipt = workload("openclaw"); + const source = { sandboxName: "alpha", agentType: "openclaw", workload: receipt }; expect(() => prepareManagedSnapshotProfileRestore(source, sandbox("openclaw", receipt), provider(false)), ).toThrow(/does not accept the snapshot workload receipt/u); @@ -167,6 +180,16 @@ describe("managed snapshot profile restore", () => { provider(true, false), ), ).toThrow(/does not support managed-profile restore/u); + expect(() => + prepareManagedSnapshotProfileRestore( + source, + { ...sandbox("openclaw", receipt), name: "beta" }, + provider(), + ), + ).toThrow(/requires a managed image or startup-profile rebind/u); + expect(() => + prepareManagedSnapshotProfileRestore(source, sandbox("hermes"), provider()), + ).toThrow(/requires a managed image or startup-profile rebind/u); }); it("fails before a managed cross-sandbox clone can reach image-only creation", () => { diff --git a/src/lib/actions/sandbox/snapshot/managed-profile.ts b/src/lib/actions/sandbox/snapshot/managed-profile.ts index a35ceaa9062..f8d44b8344f 100644 --- a/src/lib/actions/sandbox/snapshot/managed-profile.ts +++ b/src/lib/actions/sandbox/snapshot/managed-profile.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { isDeepStrictEqual } from "node:util"; import { fingerprintManagedStartupProfile } from "../../../onboard/managed-startup/profile"; import type { RuntimeProviderBundle, @@ -64,9 +63,10 @@ export function readManagedSnapshotProfileAuthority( /** * Validate an in-place managed-profile restore against the selected provider - * and the current exact workload. PR3.8 restores profile-backed state only - * when no image/profile rebind is required; cross-sandbox rebind and activation - * are intentionally owned by the later clone transaction. + * and both durable workload authorities. A same-name rebuild may replace the + * managed image and startup profile before state is restored, so runtime + * completion is bound to the replacement profile. Cross-sandbox rebind and + * activation remain owned by the later clone transaction. */ export function prepareManagedSnapshotProfileRestore( source: ManagedSnapshotProfileSource, @@ -113,16 +113,16 @@ export function prepareManagedSnapshotProfileRestore( `target '${target.name}' is not the snapshot's managed workload`, ); } - if ( - targetAuthority.agent !== sourceAuthority.agent || - !isDeepStrictEqual(targetAuthority.receipt, sourceAuthority.receipt) || - !isDeepStrictEqual(targetAuthority.contract, sourceAuthority.contract) || - !isDeepStrictEqual(targetAuthority.profile, sourceAuthority.profile) - ) { + if (target.name !== source.sandboxName || targetAuthority.agent !== sourceAuthority.agent) { throw new ManagedSnapshotProfileRestoreError( `target '${target.name}' requires a managed image or startup-profile rebind`, ); } + if (!provider.workload.acceptsReceipt(targetAuthority.receipt)) { + throw new ManagedSnapshotProfileRestoreError( + `provider '${provider.identity.id}' does not accept the target workload receipt`, + ); + } return Object.freeze({ schemaVersion: 1 as const, @@ -131,8 +131,8 @@ export function prepareManagedSnapshotProfileRestore( targetSandboxName: target.name, authority: sourceAuthority, providerRestoreAuthority: { - agent: sourceAuthority.agent, - profileFingerprint: fingerprintManagedStartupProfile(sourceAuthority.profile), + agent: targetAuthority.agent, + profileFingerprint: fingerprintManagedStartupProfile(targetAuthority.profile), }, }); } diff --git a/src/lib/adapters/openshell/client.test.ts b/src/lib/adapters/openshell/client.test.ts index 5bd28eec85c..038be1d759d 100644 --- a/src/lib/adapters/openshell/client.test.ts +++ b/src/lib/adapters/openshell/client.test.ts @@ -200,6 +200,7 @@ describe("openshell helpers", () => { runOpenshellCommand("openshell", ["status"], { timeout: 4321, killSignal: "SIGKILL", + maxBuffer: 65432, spawnSyncImpl, }); captureOpenshellCommand("openshell", ["status"], { @@ -209,7 +210,7 @@ describe("openshell helpers", () => { }); expect(observedOptions).toEqual([ - { timeout: 4321, killSignal: "SIGKILL", maxBuffer: undefined }, + { timeout: 4321, killSignal: "SIGKILL", maxBuffer: 65432 }, { timeout: 9876, killSignal: undefined, maxBuffer: 123456 }, ]); }); diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index 5e42cb983c8..012546d0422 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -47,6 +47,7 @@ export interface RunOpenshellOptions extends OpenshellSpawnOptions { stdio?: SpawnSyncOptions["stdio"]; input?: string; killSignal?: SpawnSyncOptions["killSignal"]; + maxBuffer?: number; } export interface CaptureOpenshellOptions extends OpenshellSpawnOptions { @@ -147,11 +148,18 @@ function isIgnoredTimeout(error: Error, opts: OpenshellSpawnOptions): boolean { return opts.ignoreError === true && (error as NodeJS.ErrnoException).code === "ETIMEDOUT"; } -function isIgnoredCaptureError(error: Error, opts: CaptureOpenshellOptions): boolean { - if (isIgnoredTimeout(error, opts)) return true; +function isIgnoredBufferOverflow(error: Error, opts: OpenshellSpawnOptions): boolean { return opts.ignoreError === true && (error as NodeJS.ErrnoException).code === "ENOBUFS"; } +function isIgnoredRunError(error: Error, opts: RunOpenshellOptions): boolean { + return isIgnoredTimeout(error, opts) || isIgnoredBufferOverflow(error, opts); +} + +function isIgnoredCaptureError(error: Error, opts: CaptureOpenshellOptions): boolean { + return isIgnoredTimeout(error, opts) || isIgnoredBufferOverflow(error, opts); +} + function shouldIncludeStderr(opts: CaptureOpenshellOptions): boolean { return opts.includeStderr === true || opts.ignoreError !== true; } @@ -207,9 +215,10 @@ export function runOpenshellCommand( input: opts.input, timeout: opts.timeout, killSignal: opts.killSignal, + maxBuffer: opts.maxBuffer, }); if (result.error) { - if (isIgnoredTimeout(result.error, opts)) { + if (isIgnoredRunError(result.error, opts)) { return result; } return handleSpawnError(binary, args, result.error, opts); diff --git a/src/lib/adapters/openshell/runtime.test.ts b/src/lib/adapters/openshell/runtime.test.ts index f74421c5328..ad40ae62628 100644 --- a/src/lib/adapters/openshell/runtime.test.ts +++ b/src/lib/adapters/openshell/runtime.test.ts @@ -30,6 +30,16 @@ function blockingExecutable(name: string): string { return filePath; } +function largeOutputExecutable(name: string): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-runtime-test-")); + directories.push(directory); + const filePath = path.join(directory, name); + fs.writeFileSync(filePath, `#!${process.execPath}\nprocess.stdout.write("x".repeat(1024));\n`, { + mode: 0o755, + }); + return filePath; +} + afterEach(() => { vi.unstubAllEnvs(); for (const directory of directories.splice(0)) { @@ -50,6 +60,23 @@ describe("runOpenshell", () => { expect((result.error as NodeJS.ErrnoException | undefined)?.code).toBe("ETIMEDOUT"); expect(result.signal).toBe("SIGKILL"); }); + + it("enforces the caller's output bound when stdout is captured (#9875)", () => { + const exit = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`exit ${String(code)}`); + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + const result = runOpenshell([], { + openshellBinary: largeOutputExecutable("openshell"), + ignoreError: true, + maxBuffer: 64, + stdio: ["ignore", "pipe", "pipe"], + }); + + expect((result.error as NodeJS.ErrnoException | undefined)?.code).toBe("ENOBUFS"); + expect(exit).not.toHaveBeenCalled(); + }); }); describe("captureResolvedOpenshell", () => { diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index 9ae7a711574..f4fd2537907 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -59,6 +59,7 @@ export function runOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { ignoreError: opts.ignoreError, timeout: opts.timeout, killSignal: opts.killSignal, + maxBuffer: opts.maxBuffer, errorLine: console.error, exit: (code: number) => process.exit(code), }); diff --git a/src/lib/agent/base-image-hermes-resolution.test.ts b/src/lib/agent/base-image-hermes-resolution.test.ts index d1997eafa9c..6da990b3020 100644 --- a/src/lib/agent/base-image-hermes-resolution.test.ts +++ b/src/lib/agent/base-image-hermes-resolution.test.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -53,6 +55,13 @@ const platformRef = `ghcr.io/nvidia/nemoclaw/hermes-sandbox-base@${platformDiges const imageId = `sha256:${"b".repeat(64)}`; const createdBuildContexts: string[] = []; let trackedRef = ""; +let testRoot = ""; + +function stageHermesSandbox() { + const result = createAgentSandbox(makeAgent(), { rootDir: testRoot }); + createdBuildContexts.push(result.buildCtx); + return result; +} describe("Hermes base-image resolver integration", () => { beforeEach(() => { @@ -63,6 +72,7 @@ describe("Hermes base-image resolver integration", () => { sourceMocks.nearestTags.mockReturnValue([]); dockerMocks.infoFormat.mockReturnValue("linux/aarch64\n"); dockerMocks.pull.mockReturnValue({ status: 1 }); + testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-resolution-test-")); const dockerfile = fs.readFileSync(makeAgent().dockerfilePath ?? "", "utf8"); trackedRef = @@ -110,11 +120,11 @@ describe("Hermes base-image resolver integration", () => { for (const buildCtx of createdBuildContexts.splice(0)) { fs.rmSync(buildCtx, { force: true, recursive: true }); } + fs.rmSync(testRoot, { force: true, recursive: true }); }); it("stages Hermes on aarch64 with a Dockerfile-pinned platform digest produced by the resolver path (#6313)", () => { - const result = createAgentSandbox(makeAgent()); - createdBuildContexts.push(result.buildCtx); + const result = stageHermesSandbox(); expect(fs.readFileSync(result.stagedDockerfile, "utf8")).toContain( `ARG BASE_IMAGE=${platformRef}`, @@ -209,8 +219,7 @@ describe("Hermes base-image resolver integration", () => { }, 30_000); it("reuses an outer resolver's pinned platform digest only during its rebuild lease (#7144)", () => { - const outer = createAgentSandbox(makeAgent()); - createdBuildContexts.push(outer.buildCtx); + const outer = stageHermesSandbox(); const resolutionMetadata = outer.baseImageResolutionMetadata; expect(resolutionMetadata).not.toBeNull(); vi.stubEnv("NEMOCLAW_HERMES_SANDBOX_BASE_IMAGE_REF", platformRef); @@ -223,8 +232,7 @@ describe("Hermes base-image resolver integration", () => { ); try { - const inner = createAgentSandbox(makeAgent()); - createdBuildContexts.push(inner.buildCtx); + const inner = stageHermesSandbox(); expect(fs.readFileSync(inner.stagedDockerfile, "utf8")).toContain( `ARG BASE_IMAGE=${platformRef}`, ); @@ -233,7 +241,7 @@ describe("Hermes base-image resolver integration", () => { restore(); } - expect(() => createAgentSandbox(makeAgent())).toThrow( + expect(() => stageHermesSandbox()).toThrow( `Hermes final image does not accept base image ref '${platformRef}'`, ); }, 30_000); diff --git a/src/lib/core/repository-root.ts b/src/lib/core/repository-root.ts new file mode 100644 index 00000000000..939b72dc502 --- /dev/null +++ b/src/lib/core/repository-root.ts @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +/** Repository root shared by source and compiled CLI modules. */ +export const REPOSITORY_ROOT = path.resolve(__dirname, "..", "..", ".."); diff --git a/src/lib/messaging/applier/agent-config.ts b/src/lib/messaging/applier/agent-config.ts index b81aefc0887..3bef9e6d268 100644 --- a/src/lib/messaging/applier/agent-config.ts +++ b/src/lib/messaging/applier/agent-config.ts @@ -66,8 +66,13 @@ export async function applyAgentConfigAtOpenShell( } const enabledRender = filterEnabledPlanEntries(plan, plan.agentRender); + const disabledChannelIds = new Set(plan.disabledChannels); + const disabledJsonRender = plan.agentRender.filter( + (entry): entry is SandboxMessagingJsonRenderPlan => + entry.kind === "json-fragment" && disabledChannelIds.has(entry.channelId), + ); - for (const [target, render] of groupRenderByTarget(enabledRender)) { + for (const [target, render] of groupRenderByTarget([...enabledRender, ...disabledJsonRender])) { const resolvedTarget = resolveSandboxAgentConfigTarget(target, plan.agent); const kind = render[0]?.kind; if (!kind) continue; @@ -77,7 +82,19 @@ export async function applyAgentConfigAtOpenShell( const existing = readSandboxFile(plan.sandboxName, resolvedTarget, options.runOpenshell); const contents = kind === "json-fragment" - ? applyJsonFragments(plan, existing, render.filter(isJsonRender), resolvedTarget) + ? applyJsonFragments( + plan, + existing, + render.filter( + (entry): entry is SandboxMessagingJsonRenderPlan => + isJsonRender(entry) && !disabledChannelIds.has(entry.channelId), + ), + render.filter( + (entry): entry is SandboxMessagingJsonRenderPlan => + isJsonRender(entry) && disabledChannelIds.has(entry.channelId), + ), + resolvedTarget, + ) : applyEnvLines(existing, render.filter(isEnvLinesRender)); writeSandboxFile(plan.sandboxName, resolvedTarget, contents, options.runOpenshell); appliedTargets.push(resolvedTarget); @@ -204,11 +221,15 @@ function applyJsonFragments( plan: SandboxMessagingPlan, existing: string | undefined, render: readonly SandboxMessagingJsonRenderPlan[], + disabledRender: readonly SandboxMessagingJsonRenderPlan[], target: string, ): string { const format = target.endsWith(".yaml") || target.endsWith(".yml") ? "yaml" : "json"; const root = parseStructuredConfig(existing, target, format); const rules = credentialPlaceholderRules(plan); + for (const entry of disabledRender) { + deleteJsonPath(root, entry.path); + } for (const entry of render) { setJsonPath( root, @@ -220,6 +241,23 @@ function applyJsonFragments( return format === "yaml" ? YAML.stringify(root) : JSON.stringify(root, null, 2) + "\n"; } +function deleteJsonPath(root: Record, pathValue: string): void { + const segments = pathValue.split(".").filter(Boolean); + if (segments.length === 0) { + throw new Error("Messaging render path must not be empty."); + } + let cursor: Record = root; + for (const segment of segments.slice(0, -1)) { + assertSafeObjectKey(segment, "Messaging render path"); + const next = cursor[segment]; + if (!isObjectRecord(next)) return; + cursor = next as Record; + } + const finalSegment = segments[segments.length - 1] as string; + assertSafeObjectKey(finalSegment, "Messaging render path"); + delete cursor[finalSegment]; +} + function parseStructuredConfig( existing: string | undefined, target: string, diff --git a/src/lib/messaging/applier/openshell-provider.ts b/src/lib/messaging/applier/openshell-provider.ts index 32577df50e1..ec94c430c38 100644 --- a/src/lib/messaging/applier/openshell-provider.ts +++ b/src/lib/messaging/applier/openshell-provider.ts @@ -1,13 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { inspectGatewayCredentialOnlyProviderBinding } from "../../onboard/gateway-provider-metadata"; +import { REPOSITORY_ROOT } from "../../core/repository-root"; import { redact } from "../../security/redact"; import type { SandboxMessagingCredentialBindingPlan, SandboxMessagingPlan } from "../manifest"; -import type { - MessagingCredentialApplyOptions, - MessagingCredentialApplyResult, - MessagingOpenShellRunner, -} from "./types"; +import { + ensureMessagingCredentialProviderProfile, + MESSAGING_CREDENTIAL_PROVIDER_TYPE, +} from "../provider-profile"; +import type { MessagingCredentialApplyOptions, MessagingCredentialApplyResult } from "./types"; import { filterEnabledPlanEntries } from "./plan-filter"; type MessagingCredentialApplyEntry = MessagingCredentialApplyResult["upserted"][number]; @@ -27,11 +29,35 @@ export function applyCredentialsAtOpenShell( const upserted: MessagingCredentialApplyEntry[] = []; const reused: MessagingCredentialReuseEntry[] = []; const missing: MessagingMissingCredentialEntry[] = []; + const activeBindings = filterEnabledPlanEntries(plan, plan.credentialBindings); - for (const binding of filterEnabledPlanEntries(plan, plan.credentialBindings)) { + if (activeBindings.length > 0) { + ensureMessagingCredentialProviderProfile({ + root: REPOSITORY_ROOT, + runOpenshell: (args, runOptions) => runOpenshell(args, runOptions), + }); + } + + for (const binding of activeBindings) { const credential = readCredentialEnv(env, binding.providerEnvKey); + const providerState = inspectGatewayCredentialOnlyProviderBinding( + { + name: binding.providerName, + type: MESSAGING_CREDENTIAL_PROVIDER_TYPE, + credentialKey: binding.providerEnvKey, + }, + runOpenshell, + ); + if (providerState.kind === "indeterminate") { + throw new Error(`Could not inspect messaging provider '${binding.providerName}'.`); + } + if (providerState.kind === "collision") { + throw new Error( + `Messaging provider '${binding.providerName}' does not match the required endpointless credential binding.`, + ); + } if (!credential) { - if (providerExistsInGateway(binding.providerName, runOpenshell)) { + if (providerState.kind === "exact") { reused.push(toReuseEntry(binding)); } else { missing.push(toMissingEntry(binding)); @@ -39,9 +65,7 @@ export function applyCredentialsAtOpenShell( continue; } - const action = providerExistsInGateway(binding.providerName, runOpenshell) - ? "update" - : "create"; + const action = providerState.kind === "exact" ? "update" : "create"; const result = runOpenshell( buildProviderArgs(action, binding.providerName, binding.providerEnvKey), { @@ -50,12 +74,24 @@ export function applyCredentialsAtOpenShell( stdio: ["ignore", "pipe", "pipe"], }, ); - const status = result.status ?? 0; - if (status !== 0) { + if (result.status !== 0) { throw new Error( `Failed to ${action} messaging provider '${binding.providerName}': ${compactOutput(result)}`, ); } + const verified = inspectGatewayCredentialOnlyProviderBinding( + { + name: binding.providerName, + type: MESSAGING_CREDENTIAL_PROVIDER_TYPE, + credentialKey: binding.providerEnvKey, + }, + runOpenshell, + ); + if (verified.kind !== "exact") { + throw new Error( + `OpenShell did not confirm messaging provider '${binding.providerName}' after ${action}.`, + ); + } upserted.push({ channelId: binding.channelId, credentialId: binding.credentialId, @@ -89,17 +125,6 @@ function readCredentialEnv(env: NodeJS.ProcessEnv, envKey: string): string | nul return normalized || null; } -function providerExistsInGateway( - providerName: string, - runOpenshell: MessagingOpenShellRunner, -): boolean { - const result = runOpenshell(["provider", "get", providerName], { - ignoreError: true, - stdio: ["ignore", "ignore", "ignore"], - }); - return (result.status ?? 0) === 0; -} - function buildProviderArgs( action: "create" | "update", providerName: string, @@ -112,7 +137,7 @@ function buildProviderArgs( "--name", providerName, "--type", - "generic", + MESSAGING_CREDENTIAL_PROVIDER_TYPE, "--credential", credentialEnv, ] diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 2b744c8b6ac..9319b6d8ab5 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -386,7 +386,7 @@ describe("MessagingSetupApplier", () => { } }); - it("upserts OpenShell generic providers from plan credential bindings", async () => { + it("upserts profile-backed OpenShell providers from plan credential bindings (#9875)", async () => { const plan = await buildOnboardPlan( { TELEGRAM_BOT_TOKEN: "123456:telegram-token", @@ -399,10 +399,23 @@ describe("MessagingSetupApplier", () => { args: readonly string[]; env?: Readonly>; }> = []; + const created = new Map(); const runOpenshell: MessagingOpenShellRunner = (args, options) => { calls.push({ args, env: options?.env }); - if (args[0] === "provider" && args[1] === "get") { - return { status: args[2] === "demo-slack-bridge" ? 0 : 1 }; + switch (args[1]) { + case "get": { + const name = String(args[2]); + const credentialKey = + name === "demo-slack-bridge" ? "SLACK_BOT_TOKEN" : created.get(name); + return credentialKey + ? { + status: 0, + stdout: `Name: ${name}\nType: nemoclaw-mcp-v1\nCredential keys: ${credentialKey}\nConfig keys: \n`, + } + : { status: 1, stderr: `provider '${name}' not found` }; + } + case "create": + created.set(String(args[3]), String(args[7])); } return { status: 0 }; }; @@ -417,6 +430,7 @@ describe("MessagingSetupApplier", () => { }); expect(calls.map((call) => call.args)).toEqual([ + ["provider", "profile", "import", "--file", expect.stringMatching(/nemoclaw-mcp-v1\.yaml$/)], ["provider", "get", "demo-telegram-bridge"], [ "provider", @@ -424,12 +438,14 @@ describe("MessagingSetupApplier", () => { "--name", "demo-telegram-bridge", "--type", - "generic", + "nemoclaw-mcp-v1", "--credential", "TELEGRAM_BOT_TOKEN", ], + ["provider", "get", "demo-telegram-bridge"], ["provider", "get", "demo-slack-bridge"], ["provider", "update", "demo-slack-bridge", "--credential", "SLACK_BOT_TOKEN"], + ["provider", "get", "demo-slack-bridge"], ["provider", "get", "demo-slack-app"], [ "provider", @@ -437,12 +453,13 @@ describe("MessagingSetupApplier", () => { "--name", "demo-slack-app", "--type", - "generic", + "nemoclaw-mcp-v1", "--credential", "SLACK_APP_TOKEN", ], + ["provider", "get", "demo-slack-app"], ]); - expect(calls[1]?.env).toEqual({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }); + expect(calls[2]?.env).toEqual({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }); expect(result.upserted.map((entry) => `${entry.action}:${entry.providerName}`)).toEqual([ "create:demo-telegram-bridge", "update:demo-slack-bridge", @@ -460,16 +477,86 @@ describe("MessagingSetupApplier", () => { expect(JSON.stringify(result)).not.toContain("slack-token"); }); + it("rejects a legacy generic provider instead of reusing its credential (#9875)", async () => { + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const calls: string[] = []; + const runOpenshell: MessagingOpenShellRunner = (args) => { + calls.push(args.join(" ")); + return args[1] === "get" + ? { + status: 0, + stdout: + "Name: demo-telegram-bridge\nType: generic\nCredential keys: TELEGRAM_BOT_TOKEN\nConfig keys: \n", + } + : { status: 0 }; + }; + + expect(() => + MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: { TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, + runOpenshell, + }), + ).toThrow(/does not match the required endpointless credential binding/); + expect(calls.some((command) => /provider (create|update)/u.test(command))).toBe(false); + }); + + it("rejects credential-free reuse backed by an incompatible global profile (#9875)", async () => { + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const calls: string[] = []; + + expect(() => + MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: {}, + runOpenshell: (args) => { + calls.push(args.join(" ")); + switch (`${args[1]} ${args[2]}`) { + case "profile import": + return { status: 1, stderr: "profile already exists" }; + case "profile export": + return { + status: 0, + stdout: JSON.stringify({ + id: "nemoclaw-mcp-v1", + credentials: [], + endpoints: ["https://foreign.invalid"], + binaries: [], + inference_capable: false, + }), + }; + default: + return { + status: 0, + stdout: + "Name: demo-telegram-bridge\nType: nemoclaw-mcp-v1\nCredential keys: TELEGRAM_BOT_TOKEN\nConfig keys: \n", + }; + } + }, + }), + ).toThrow(/does not match NemoClaw's endpointless messaging credential contract/u); + expect(calls.some((command) => /provider (create|update)/u.test(command))).toBe(false); + }); + it("redacts OpenShell provider failure output", async () => { const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "tokensecretvalue" }, ["telegram"]); const runOpenshell: MessagingOpenShellRunner = (args) => { - if (args[0] === "provider" && args[1] === "get") { - return { status: 1 }; + switch (args[1]) { + case "profile": + return { status: 0 }; + case "get": + return { + status: 1, + stderr: "provider 'demo-telegram-bridge' not found", + }; + default: + return { + status: 1, + stderr: "provider rejected TELEGRAM_BOT_TOKEN=tokensecretvalue", + }; } - return { - status: 1, - stderr: "provider rejected TELEGRAM_BOT_TOKEN=tokensecretvalue", - }; }; let message = ""; @@ -486,6 +573,99 @@ describe("MessagingSetupApplier", () => { expect(message).not.toContain("tokensecretvalue"); }); + it("does not create a provider after an ambiguous lookup failure (#9875)", async () => { + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const calls: string[] = []; + expect(() => + MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: { TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, + runOpenshell: (args) => { + calls.push(args.join(" ")); + return args[1] === "profile" + ? { status: 0 } + : { status: 1, stderr: "gateway unavailable" }; + }, + }), + ).toThrow("Could not inspect messaging provider 'demo-telegram-bridge'."); + expect(calls.some((command) => command.startsWith("provider create"))).toBe(false); + }); + + it("treats a null provider mutation status as failure (#9875)", async () => { + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + + expect(() => + MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: { TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, + runOpenshell: (args) => { + switch (args[1]) { + case "profile": + return { status: 0 }; + case "get": + return { status: 1, stderr: "provider 'demo-telegram-bridge' not found" }; + default: + return { status: null, stderr: "transport closed" }; + } + }, + }), + ).toThrow("Failed to create messaging provider 'demo-telegram-bridge'"); + }); + + it("rejects a provider mutation whose exact postcondition is absent (#9875)", async () => { + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + let lookups = 0; + + expect(() => + MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: { TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, + runOpenshell: (args) => { + switch (args[1]) { + case "profile": + case "create": + return { status: 0 }; + default: + lookups += 1; + return lookups === 1 + ? { status: 1, stderr: "provider 'demo-telegram-bridge' not found" } + : { + status: 0, + stdout: + "Name: demo-telegram-bridge\nType: generic\nCredential keys: TELEGRAM_BOT_TOKEN\nConfig keys: \n", + }; + } + }, + }), + ).toThrow("OpenShell did not confirm messaging provider 'demo-telegram-bridge' after create."); + }); + + it("does not mutate after a not-found message with an unavailable status (#9875)", async () => { + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const calls: string[] = []; + + expect(() => + MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: { TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, + runOpenshell: (args) => { + calls.push(args.join(" ")); + return args[1] === "profile" + ? { status: 0 } + : { + status: 1, + stderr: 'Error: status: Unavailable, message: "provider not found"', + }; + }, + }), + ).toThrow(/Could not inspect messaging provider/u); + expect(calls.some((command) => /provider (create|update)/u.test(command))).toBe(false); + }); + it("applies agent config render plans into sandbox files through OpenShell", async () => { const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ "telegram", @@ -587,6 +767,7 @@ describe("MessagingSetupApplier", () => { it("renders every built-in Hermes credential and allowlist through the sandbox applier", async () => { const plan = await buildOnboardPlan(ALL_CHANNEL_ENV, ALL_CHANNELS, "hermes"); const files: Record = {}; + const providers = new Map(); const runOpenshell: MessagingOpenShellRunner = (args, options) => { const target = String(args.at(-1)); const reading = args.includes("cat") && options?.input === undefined; @@ -599,8 +780,23 @@ describe("MessagingSetupApplier", () => { const credentialResult = MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { env: ALL_CHANNEL_ENV, - runOpenshell: (args) => - args[0] === "provider" && args[1] === "get" ? { status: 1 } : { status: 0 }, + runOpenshell: (args) => { + switch (args[1]) { + case "get": { + const name = String(args[2]); + const credentialKey = providers.get(name); + return credentialKey + ? { + status: 0, + stdout: `Name: ${name}\nType: nemoclaw-mcp-v1\nCredential keys: ${credentialKey}\nConfig keys: \n`, + } + : { status: 1, stderr: `provider '${name}' not found` }; + } + case "create": + providers.set(String(args[3]), String(args[7])); + } + return { status: 0 }; + }, }); const policyResult = MessagingSetupApplier.applyPolicyAtOpenShell(plan, { applyPresets: (_sandboxName, presetNames, context) => { @@ -674,13 +870,13 @@ describe("MessagingSetupApplier", () => { }); it("excludes disabled channels at the applier boundary", async () => { - const plan = await withEnv( - { - TELEGRAM_BOT_TOKEN: "123456:telegram-token", - SLACK_BOT_TOKEN: "xoxb-slack-token", - SLACK_APP_TOKEN: "xapp-slack-token", - }, - () => + const environment = { + TELEGRAM_BOT_TOKEN: "123456:telegram-token", + SLACK_BOT_TOKEN: "xoxb-slack-token", + SLACK_APP_TOKEN: "xapp-slack-token", + }; + const [plan, enabledPlan] = await withEnv(environment, () => + Promise.all([ planner().buildPlan({ sandboxName: "demo", agent: "openclaw", @@ -689,6 +885,14 @@ describe("MessagingSetupApplier", () => { configuredChannels: ["telegram", "slack"], disabledChannels: ["telegram"], }), + planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["telegram", "slack"], + }), + ]), ); expect(plan.disabledChannels).toEqual(["telegram"]); expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual([ @@ -715,6 +919,7 @@ describe("MessagingSetupApplier", () => { ]); const providerCalls: string[][] = []; + const providers = new Map(); const credentialResult = MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { env: { TELEGRAM_BOT_TOKEN: "123456:telegram-token", @@ -723,7 +928,20 @@ describe("MessagingSetupApplier", () => { }, runOpenshell: (args) => { providerCalls.push([...args]); - if (args[0] === "provider" && args[1] === "get") return { status: 1 }; + switch (args[1]) { + case "get": { + const name = String(args[2]); + const credentialKey = providers.get(name); + return credentialKey + ? { + status: 0, + stdout: `Name: ${name}\nType: nemoclaw-mcp-v1\nCredential keys: ${credentialKey}\nConfig keys: \n`, + } + : { status: 1, stderr: `provider '${name}' not found` }; + } + case "create": + providers.set(String(args[3]), String(args[7])); + } return { status: 0 }; }, }); @@ -742,21 +960,31 @@ describe("MessagingSetupApplier", () => { expect(policyResult.appliedPolicyKeys).toEqual(["slack"]); const files: Record = { - "/sandbox/.openclaw/openclaw.json": "{}", + "/sandbox/.openclaw/openclaw.json": JSON.stringify({ + channels: { telegram: { enabled: true, stale: true } }, + }), }; - await MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { - runOpenshell: (args, options) => { - const target = String(args.at(-1)); - if (args.includes("cat") && options?.input === undefined) { - return { status: files[target] === undefined ? 1 : 0, stdout: files[target] ?? "" }; - } - if (options?.input !== undefined) { - files[target] = options.input; - return { status: 0 }; - } - return { status: 1 }; + await MessagingSetupApplier.applyAgentConfigAtOpenShell( + { + ...plan, + // Stop/rebuild plans retain the prior render entries so the applier can + // remove stale configuration restored by OpenClaw doctor. + agentRender: enabledPlan.agentRender, }, - }); + { + runOpenshell: (args, options) => { + const target = String(args.at(-1)); + if (args.includes("cat") && options?.input === undefined) { + return { status: files[target] === undefined ? 1 : 0, stdout: files[target] ?? "" }; + } + if (options?.input !== undefined) { + files[target] = options.input; + return { status: 0 }; + } + return { status: 1 }; + }, + }, + ); const openclawConfig = JSON.parse(files["/sandbox/.openclaw/openclaw.json"] ?? "{}"); expect(openclawConfig.channels.telegram).toBeUndefined(); expect(openclawConfig.channels.slack.accounts.default).toMatchObject({ @@ -766,6 +994,65 @@ describe("MessagingSetupApplier", () => { }); }); + it("removes hook-created WeChat config when the channel is disabled", async () => { + const enabledPlan = await buildOnboardPlan( + { + WECHAT_BOT_TOKEN: "wechat-token", + WECHAT_ACCOUNT_ID: "wechat-account", + }, + ["wechat"], + ); + const stoppedPlan = await planner().buildChannelStopPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + channelId: "wechat", + sandboxEntry: { + name: "demo", + messaging: { + schemaVersion: 1, + plan: compactSandboxMessagingPlanForPersistence( + enabledPlan, + ) as unknown as SandboxMessagingPlan, + }, + }, + }); + expect(stoppedPlan?.disabledChannels).toEqual(["wechat"]); + + const files: Record = { + "/sandbox/.openclaw/openclaw.json": JSON.stringify({ + channels: { + "openclaw-weixin": { + accounts: { + "wechat-account": { enabled: true }, + }, + }, + }, + plugins: { + entries: { + "openclaw-weixin": { enabled: true }, + }, + }, + preserved: true, + }), + }; + await MessagingSetupApplier.applyAgentConfigAtOpenShell(stoppedPlan!, { + runOpenshell: (args, options) => { + const target = String(args.at(-1)); + const reading = args.includes("cat") && options?.input === undefined; + const written = options?.input; + Object.assign(files, written === undefined ? {} : { [target]: written }); + return reading + ? { status: files[target] === undefined ? 1 : 0, stdout: files[target] ?? "" } + : { status: written === undefined ? 1 : 0 }; + }, + }); + + const openclawConfig = JSON.parse(files["/sandbox/.openclaw/openclaw.json"] ?? "{}"); + expect(openclawConfig.channels["openclaw-weixin"]).toBeUndefined(); + expect(openclawConfig.plugins.entries["openclaw-weixin"]).toBeUndefined(); + expect(openclawConfig.preserved).toBe(true); + }); + it("runs post-install hook implementations and writes their build-file outputs", async () => { const plan = await buildOnboardPlan( { diff --git a/src/lib/messaging/applier/types.ts b/src/lib/messaging/applier/types.ts index 70e6a77be43..63c4e0742a0 100644 --- a/src/lib/messaging/applier/types.ts +++ b/src/lib/messaging/applier/types.ts @@ -49,10 +49,16 @@ export interface MessagingOpenShellRunOptions { readonly ignoreError?: boolean; readonly env?: Readonly>; readonly input?: string; + readonly maxBuffer?: number; + readonly suppressOutput?: boolean; readonly stdio?: readonly unknown[]; + readonly timeout?: number; } export interface MessagingOpenShellRunResult { + readonly error?: unknown; + readonly output?: unknown; + readonly signal?: unknown; readonly status?: number | null; readonly stdout?: unknown; readonly stderr?: unknown; diff --git a/src/lib/messaging/channels/teams/contract.ts b/src/lib/messaging/channels/teams/contract.ts new file mode 100644 index 00000000000..527a2ec3584 --- /dev/null +++ b/src/lib/messaging/channels/teams/contract.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Schema-owned identity for the stock Microsoft Teams OpenClaw webhook render. */ +export const TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT = { + channelId: "teams", + renderId: "teams-openclaw-channel", + hookId: "teams-openclaw-channel", + handlerId: "common.staticOutputs", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + configPath: "channels.msteams", + webhookPath: "/api/messages", +} as const; + +export interface TeamsManagedStartupFieldAuthorization { + readonly path: readonly string[]; + readonly value: Record; +} + +export function authorizeTeamsOpenClawWebhookField( + entry: unknown, +): readonly TeamsManagedStartupFieldAuthorization[] { + if (!isPlainDataObject(entry)) return []; + const contract = TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT; + if ( + ownDataPropertyValue(entry, "channelId") !== contract.channelId || + ownDataPropertyValue(entry, "renderId") !== contract.renderId || + ownDataPropertyValue(entry, "hookId") !== contract.hookId || + ownDataPropertyValue(entry, "handler") !== contract.handlerId || + ownDataPropertyValue(entry, "kind") !== contract.kind || + ownDataPropertyValue(entry, "agent") !== contract.agent || + ownDataPropertyValue(entry, "target") !== contract.target || + ownDataPropertyValue(entry, "path") !== contract.configPath + ) { + return []; + } + + const value = ownDataPropertyValue(entry, "value"); + if (!isPlainDataObject(value)) return []; + const webhook = ownDataPropertyValue(value, "webhook"); + if ( + !isPlainDataObject(webhook) || + !hasExactlyOwnDataProperties(webhook, ["path", "port"]) || + !isTcpPort(ownDataPropertyValue(webhook, "port")) || + ownDataPropertyValue(webhook, "path") !== contract.webhookPath + ) { + return []; + } + + return [{ path: ["value", "webhook"], value: webhook }]; +} + +function isPlainDataObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function ownDataPropertyValue(value: Record, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; +} + +function hasExactlyOwnDataProperties( + value: Record, + expected: readonly string[], +): boolean { + const actual = Object.getOwnPropertyNames(value).sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function isTcpPort(value: unknown): value is number { + return Number.isInteger(value) && (value as number) >= 1 && (value as number) <= 65_535; +} diff --git a/src/lib/messaging/channels/teams/manifest.ts b/src/lib/messaging/channels/teams/manifest.ts index f2d88d42ac8..092ae1e1560 100644 --- a/src/lib/messaging/channels/teams/manifest.ts +++ b/src/lib/messaging/channels/teams/manifest.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { ChannelManifest } from "../../manifest"; +import { TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT } from "./contract.ts"; export const teamsManifest = { schemaVersion: 1, @@ -103,12 +104,12 @@ export const teamsManifest = { }, render: [ { - id: "teams-openclaw-channel", - kind: "json-fragment", - agent: "openclaw", - target: "openclaw.json", + id: TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.renderId, + kind: TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.kind, + agent: TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.agent, + target: TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.target, fragment: { - path: "channels.msteams", + path: TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.configPath, value: { enabled: true, appId: "{{teamsConfig.appId}}", @@ -116,7 +117,7 @@ export const teamsManifest = { tenantId: "{{teamsConfig.tenantId}}", webhook: { port: "{{teamsConfig.webhookPort}}", - path: "/api/messages", + path: TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.webhookPath, }, healthMonitor: { enabled: false, diff --git a/src/lib/messaging/channels/wechat/contract.ts b/src/lib/messaging/channels/wechat/contract.ts index 9256161281d..b81550dd10f 100644 --- a/src/lib/messaging/channels/wechat/contract.ts +++ b/src/lib/messaging/channels/wechat/contract.ts @@ -34,11 +34,20 @@ export interface WechatManagedStartupPlaceholderAuthorization { export function authorizeWechatAccountFilePlaceholders( value: unknown, ): readonly WechatManagedStartupPlaceholderAuthorization[] { + const content = isPlainDataObject(value) ? ownDataPropertyValue(value, "content") : undefined; if ( !isPlainDataObject(value) || + !hasExactlyOwnDataProperties(value, ["content", "mode", "path"]) || !isWechatAccountFilePath(ownDataPropertyValue(value, "path")) || ownDataPropertyValue(value, "mode") !== WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.mode || - !isPlainDataObject(ownDataPropertyValue(value, "content")) + !isPlainDataObject(content) || + !hasOnlyOwnDataProperties(content, ["baseUrl", "savedAt", "token", "userId"]) || + !hasOwnDataProperty(content, "savedAt") || + !hasOwnDataProperty(content, "token") || + ownDataPropertyValue(content, "token") !== WECHAT_TOKEN_PLACEHOLDER || + !isNonEmptyString(ownDataPropertyValue(content, "savedAt")) || + !isOptionalNonEmptyString(content, "baseUrl") || + !isOptionalNonEmptyString(content, "userId") ) { return []; } @@ -75,12 +84,40 @@ function isSafeWechatAccountId(accountId: string): boolean { } function isPlainDataObject(value: unknown): value is Record { - return ( - value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype - ); + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } function ownDataPropertyValue(value: Record, key: string): unknown { const descriptor = Object.getOwnPropertyDescriptor(value, key); return descriptor && "value" in descriptor ? descriptor.value : undefined; } + +function hasOwnDataProperty(value: Record, key: string): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && "value" in descriptor; +} + +function hasExactlyOwnDataProperties( + value: Record, + expected: readonly string[], +): boolean { + const actual = Object.getOwnPropertyNames(value).sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function hasOnlyOwnDataProperties( + value: Record, + allowed: readonly string[], +): boolean { + return Object.getOwnPropertyNames(value).every((key) => allowed.includes(key)); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isOptionalNonEmptyString(value: Record, key: string): boolean { + return !hasOwnDataProperty(value, key) || isNonEmptyString(ownDataPropertyValue(value, key)); +} diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index f0fc8a76159..0555fbb79f4 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -83,6 +83,16 @@ export const wechatManifest = { }, }, }, + { + id: "wechat-openclaw-channel", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.openclaw-weixin", + value: {}, + }, + }, { id: "wechat-hermes-env", kind: "env-lines", diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index ea76afd6a2f..be732673249 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -210,6 +210,7 @@ describe("ManifestCompiler", () => { "discord:discord-openclaw-channel", "discord:discord-openclaw-plugin", "wechat:wechat-openclaw-plugin", + "wechat:wechat-openclaw-channel", "slack:slack-openclaw-channel", "slack:slack-openclaw-plugin", "whatsapp:whatsapp-openclaw-channel", diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts index 1da55c0c870..e3e94f487d8 100644 --- a/src/lib/messaging/index.ts +++ b/src/lib/messaging/index.ts @@ -11,4 +11,5 @@ export * from "./host-forward"; export * from "./hydration"; export * from "./manifest"; export * from "./persistence"; +export { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "./provider-profile"; export * from "./utils"; diff --git a/src/lib/messaging/managed-startup-placeholders.test.ts b/src/lib/messaging/managed-startup-placeholders.test.ts new file mode 100644 index 00000000000..fe4a993a3f8 --- /dev/null +++ b/src/lib/messaging/managed-startup-placeholders.test.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { authorizeTeamsOpenClawWebhookField } from "./channels/teams/contract"; +import { + authorizeWechatAccountFilePlaceholders, + WECHAT_TOKEN_PLACEHOLDER, +} from "./channels/wechat/contract"; +import { authorizeMessagingManagedStartupFields } from "./managed-startup-placeholders"; + +const TEAMS_WEBHOOK = { path: "/api/messages", port: 3978 }; +const TEAMS_ENTRY = { + channelId: "teams", + renderId: "teams-openclaw-channel", + hookId: "teams-openclaw-channel", + handler: "common.staticOutputs", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + path: "channels.msteams", + value: { webhook: TEAMS_WEBHOOK }, +}; +const WECHAT_VALUE = { + path: "openclaw-weixin/accounts/managed-startup.json", + mode: "0600", + content: { + savedAt: "2026-08-23T00:00:00.000Z", + token: WECHAT_TOKEN_PLACEHOLDER, + }, +}; +const WECHAT_ENTRY = { + channelId: "wechat", + hookId: "wechat-seed-openclaw-account", + handler: "wechat.seedOpenClawAccount", + outputId: "openclawWeixinAccountFile", + kind: "build-file", + required: true, + value: WECHAT_VALUE, +}; + +function nullPrototype>(value: T): T { + return Object.assign(Object.create(null), value) as T; +} + +describe("managed-startup messaging field authorization", () => { + it("accepts exact null-prototype Teams and WeChat contracts", () => { + const webhook = nullPrototype({ ...TEAMS_WEBHOOK }); + const teamsEntry = nullPrototype({ + ...TEAMS_ENTRY, + value: nullPrototype({ webhook }), + }); + const content = nullPrototype({ ...WECHAT_VALUE.content }); + const wechatValue = nullPrototype({ ...WECHAT_VALUE, content }); + const wechatEntry = nullPrototype({ ...WECHAT_ENTRY, value: wechatValue }); + + expect(authorizeTeamsOpenClawWebhookField(teamsEntry)).toEqual([ + { path: ["value", "webhook"], value: webhook }, + ]); + expect(authorizeWechatAccountFilePlaceholders(wechatValue)).toEqual([ + { path: ["content", "token"], value: WECHAT_TOKEN_PLACEHOLDER }, + ]); + expect(authorizeMessagingManagedStartupFields(teamsEntry, "agentRender")).toEqual([ + { path: ["value", "webhook"], value: webhook }, + ]); + expect(authorizeMessagingManagedStartupFields(wechatEntry, "buildSteps")).toEqual([ + { path: ["value", "content", "token"], value: WECHAT_TOKEN_PLACEHOLDER }, + ]); + }); + + it("rejects inherited Teams and WeChat fields", () => { + expect(authorizeTeamsOpenClawWebhookField(Object.create(TEAMS_ENTRY))).toEqual([]); + expect(authorizeWechatAccountFilePlaceholders(Object.create(WECHAT_VALUE))).toEqual([]); + expect( + authorizeMessagingManagedStartupFields(Object.create(TEAMS_ENTRY), "agentRender"), + ).toEqual([]); + expect( + authorizeMessagingManagedStartupFields(Object.create(WECHAT_ENTRY), "buildSteps"), + ).toEqual([]); + }); + + it("rejects accessors without invoking their getters", () => { + let teamsGetterCalls = 0; + const teamsEntry = { ...TEAMS_ENTRY }; + Object.defineProperty(teamsEntry, "value", { + enumerable: true, + get() { + teamsGetterCalls += 1; + return TEAMS_ENTRY.value; + }, + }); + let wechatGetterCalls = 0; + const wechatEntry = { ...WECHAT_ENTRY }; + Object.defineProperty(wechatEntry, "value", { + enumerable: true, + get() { + wechatGetterCalls += 1; + return WECHAT_ENTRY.value; + }, + }); + + expect(authorizeTeamsOpenClawWebhookField(teamsEntry)).toEqual([]); + expect(authorizeMessagingManagedStartupFields(teamsEntry, "agentRender")).toEqual([]); + expect(authorizeMessagingManagedStartupFields(wechatEntry, "buildSteps")).toEqual([]); + expect(teamsGetterCalls).toBe(0); + expect(wechatGetterCalls).toBe(0); + }); + + it("rejects surplus fields inside authorized credential values", () => { + const teamsEntry = { + ...TEAMS_ENTRY, + value: { webhook: { ...TEAMS_WEBHOOK, token: "unexpected" } }, + }; + const wechatValue = { + ...WECHAT_VALUE, + content: { ...WECHAT_VALUE.content, note: "unexpected" }, + }; + const wechatEntry = { ...WECHAT_ENTRY, value: wechatValue }; + + expect(authorizeTeamsOpenClawWebhookField(teamsEntry)).toEqual([]); + expect(authorizeWechatAccountFilePlaceholders(wechatValue)).toEqual([]); + expect(authorizeMessagingManagedStartupFields(teamsEntry, "agentRender")).toEqual([]); + expect(authorizeMessagingManagedStartupFields(wechatEntry, "buildSteps")).toEqual([]); + }); + + it("rejects malformed authorization entries", () => { + expect(authorizeTeamsOpenClawWebhookField(null)).toEqual([]); + expect(authorizeTeamsOpenClawWebhookField([])).toEqual([]); + expect(authorizeWechatAccountFilePlaceholders("invalid")).toEqual([]); + expect(authorizeWechatAccountFilePlaceholders({ ...WECHAT_VALUE, mode: "0644" })).toEqual([]); + expect(authorizeMessagingManagedStartupFields({}, "agentRender")).toEqual([]); + expect(authorizeMessagingManagedStartupFields({}, "buildSteps")).toEqual([]); + }); +}); diff --git a/src/lib/messaging/managed-startup-placeholders.ts b/src/lib/messaging/managed-startup-placeholders.ts index 58ab6111284..811599d3736 100644 --- a/src/lib/messaging/managed-startup-placeholders.ts +++ b/src/lib/messaging/managed-startup-placeholders.ts @@ -1,32 +1,39 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + authorizeTeamsOpenClawWebhookField, + type TeamsManagedStartupFieldAuthorization, +} from "./channels/teams/contract.ts"; import { authorizeWechatAccountFilePlaceholders, WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT, type WechatManagedStartupPlaceholderAuthorization, } from "./channels/wechat/contract.ts"; -export type MessagingManagedStartupPlaceholderAuthorization = - WechatManagedStartupPlaceholderAuthorization; +export type MessagingManagedStartupFieldAuthorization = + | WechatManagedStartupPlaceholderAuthorization + | TeamsManagedStartupFieldAuthorization; -export function authorizeMessagingManagedStartupPlaceholders( - step: unknown, -): readonly MessagingManagedStartupPlaceholderAuthorization[] { - if (!isPlainDataObject(step)) return []; +export function authorizeMessagingManagedStartupFields( + entry: unknown, + section: "buildSteps" | "agentRender", +): readonly MessagingManagedStartupFieldAuthorization[] { + if (section === "agentRender") return authorizeTeamsOpenClawWebhookField(entry); + if (!isPlainDataObject(entry)) return []; const contract = WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT; if ( - ownDataPropertyValue(step, "channelId") !== contract.channelId || - ownDataPropertyValue(step, "hookId") !== contract.planHookId || - ownDataPropertyValue(step, "handler") !== contract.handlerId || - ownDataPropertyValue(step, "outputId") !== contract.outputId || - ownDataPropertyValue(step, "kind") !== contract.kind || - ownDataPropertyValue(step, "required") !== contract.required + ownDataPropertyValue(entry, "channelId") !== contract.channelId || + ownDataPropertyValue(entry, "hookId") !== contract.planHookId || + ownDataPropertyValue(entry, "handler") !== contract.handlerId || + ownDataPropertyValue(entry, "outputId") !== contract.outputId || + ownDataPropertyValue(entry, "kind") !== contract.kind || + ownDataPropertyValue(entry, "required") !== contract.required ) { return []; } - return authorizeWechatAccountFilePlaceholders(ownDataPropertyValue(step, "value")).map( + return authorizeWechatAccountFilePlaceholders(ownDataPropertyValue(entry, "value")).map( (authorization) => ({ ...authorization, path: ["value", ...authorization.path], @@ -35,9 +42,9 @@ export function authorizeMessagingManagedStartupPlaceholders( } function isPlainDataObject(value: unknown): value is Record { - return ( - value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype - ); + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } function ownDataPropertyValue(value: Record, key: string): unknown { diff --git a/src/lib/messaging/provider-profile.test.ts b/src/lib/messaging/provider-profile.test.ts new file mode 100644 index 00000000000..b10b1f66d0c --- /dev/null +++ b/src/lib/messaging/provider-profile.test.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; +import path from "node:path"; + +import { REPOSITORY_ROOT } from "../core/repository-root"; +import { + ensureMessagingCredentialProviderProfile, + MESSAGING_CREDENTIAL_PROVIDER_TYPE, + messagingCredentialProviderProfilePath, +} from "./provider-profile"; + +const EXPECTED_PROFILE = JSON.stringify({ + id: MESSAGING_CREDENTIAL_PROVIDER_TYPE, + credentials: [], + endpoints: [], + binaries: [], + inference_capable: false, +}); + +describe("messaging credential provider profile", () => { + it("resolves the checked-in profile from the source repository root (#9875)", () => { + expect(messagingCredentialProviderProfilePath(REPOSITORY_ROOT)).toBe( + path.join(REPOSITORY_ROOT, "nemoclaw-blueprint", "provider-profiles", "nemoclaw-mcp-v1.yaml"), + ); + }); + + it("imports the endpointless profile from the checked-in path (#9875)", () => { + const runOpenshell = vi.fn(() => ({ status: 0 })); + + ensureMessagingCredentialProviderProfile({ + root: "/repo", + runOpenshell, + }); + + expect(runOpenshell).toHaveBeenCalledOnce(); + expect(runOpenshell).toHaveBeenCalledWith( + [ + "provider", + "profile", + "import", + "--file", + "/repo/nemoclaw-blueprint/provider-profiles/nemoclaw-mcp-v1.yaml", + ], + { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + }); + + it("validates an existing profile before accepting it (#9875)", () => { + const runOpenshell = vi + .fn() + .mockReturnValueOnce({ status: 1, stderr: "profile already exists" }) + .mockReturnValueOnce({ status: 0, stdout: EXPECTED_PROFILE }); + + expect(() => + ensureMessagingCredentialProviderProfile({ + root: "/repo", + runOpenshell, + }), + ).not.toThrow(); + + expect(runOpenshell).toHaveBeenNthCalledWith( + 2, + ["provider", "profile", "export", MESSAGING_CREDENTIAL_PROVIDER_TYPE, "--output", "json"], + { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + }); + + it("rejects an incompatible existing profile (#9875)", () => { + const runOpenshell = vi + .fn() + .mockReturnValueOnce({ status: 1, stderr: "profile already exists" }) + .mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ + id: MESSAGING_CREDENTIAL_PROVIDER_TYPE, + credentials: [], + endpoints: ["https://example.invalid"], + binaries: [], + inference_capable: false, + }), + }); + + expect(() => + ensureMessagingCredentialProviderProfile({ + root: "/repo", + runOpenshell, + }), + ).toThrow(/does not match NemoClaw's endpointless messaging credential contract/); + }); + + it("reports a failed existing-profile export separately (#9875)", () => { + const runOpenshell = vi + .fn() + .mockReturnValueOnce({ status: 1, stderr: "profile already exists" }) + .mockReturnValueOnce({ status: 1, stderr: "gateway unavailable" }); + + expect(() => + ensureMessagingCredentialProviderProfile({ + root: "/repo", + runOpenshell, + }), + ).toThrow(/already exists but could not be exported for validation/); + }); + + it.each(["not-json", `${EXPECTED_PROFILE}\n${EXPECTED_PROFILE}`])( + "rejects malformed or ambiguous existing profile output (#9875)", + (stdout) => { + const runOpenshell = vi + .fn() + .mockReturnValueOnce({ status: 1, stderr: "profile already exists" }) + .mockReturnValueOnce({ status: 0, stdout }); + + expect(() => + ensureMessagingCredentialProviderProfile({ + root: "/repo", + runOpenshell, + }), + ).toThrow(/does not match NemoClaw's endpointless messaging credential contract/); + }, + ); + + it("suppresses profile import diagnostics (#9875)", () => { + const runOpenshell = vi.fn(() => ({ + status: 1, + stderr: "request failed with discord-credential-must-not-leak", + })); + + expect(() => + ensureMessagingCredentialProviderProfile({ + root: "/repo", + runOpenshell, + }), + ).toThrow("Could not import the OpenShell messaging credential profile."); + }); +}); diff --git a/src/lib/messaging/provider-profile.ts b/src/lib/messaging/provider-profile.ts new file mode 100644 index 00000000000..7f2762cd392 --- /dev/null +++ b/src/lib/messaging/provider-profile.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +export const MESSAGING_CREDENTIAL_PROVIDER_TYPE = "nemoclaw-mcp-v1"; // gitleaks:allow + +export type EndpointlessProviderProfileRunner = ( + args: string[], + options?: { + readonly ignoreError?: boolean; + readonly stdio?: ["ignore", "pipe", "pipe"]; + }, +) => { + readonly status?: number | null; + readonly stdout?: unknown; + readonly stderr?: unknown; +}; + +function outputText(value: unknown): string { + if (Buffer.isBuffer(value)) return value.toString("utf8"); + return typeof value === "string" ? value : ""; +} + +function commandOutput(result: { readonly stdout?: unknown; readonly stderr?: unknown }): string { + return `${outputText(result.stderr)}\n${outputText(result.stdout)}`; +} + +function profileHasExpectedCredentialBoundary( + output: string, + expected: { readonly id: string; readonly inferenceCapable: boolean }, +): boolean { + try { + const profile = JSON.parse(output) as Record; + return ( + profile.id === expected.id && + Array.isArray(profile.credentials) && + profile.credentials.length === 0 && + Array.isArray(profile.endpoints) && + profile.endpoints.length === 0 && + Array.isArray(profile.binaries) && + profile.binaries.length === 0 && + profile.inference_capable === expected.inferenceCapable + ); + } catch { + return false; + } +} + +export function messagingCredentialProviderProfilePath(root: string): string { + return endpointlessProviderProfilePath(root, MESSAGING_CREDENTIAL_PROVIDER_TYPE); +} + +export function endpointlessProviderProfilePath(root: string, profileId: string): string { + return path.join(root, "nemoclaw-blueprint", "provider-profiles", `${profileId}.yaml`); +} + +export type EndpointlessProviderProfileResult = + | { readonly ok: true } + | { + readonly ok: false; + readonly reason: "export-failed" | "import-failed" | "incompatible"; + readonly diagnostic: string; + }; + +/** Import one endpointless profile or validate the exact existing contract. */ +export function ensureEndpointlessProviderProfile(input: { + readonly profileId: string; + readonly inferenceCapable: boolean; + readonly profilePath: string; + readonly runOpenshell: EndpointlessProviderProfileRunner; +}): EndpointlessProviderProfileResult { + const imported = input.runOpenshell( + ["provider", "profile", "import", "--file", input.profilePath], + { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + if (imported.status === 0) return { ok: true }; + + const importOutput = commandOutput(imported); + if (!/already exists/iu.test(importOutput)) { + return { ok: false, reason: "import-failed", diagnostic: importOutput.trim() }; + } + + const exported = input.runOpenshell( + ["provider", "profile", "export", input.profileId, "--output", "json"], + { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + if (exported.status !== 0) { + return { ok: false, reason: "export-failed", diagnostic: "" }; + } + if ( + !profileHasExpectedCredentialBoundary(outputText(exported.stdout), { + id: input.profileId, + inferenceCapable: input.inferenceCapable, + }) + ) { + return { ok: false, reason: "incompatible", diagnostic: "" }; + } + return { ok: true }; +} + +/** Register and verify the endpointless profile used by static messaging credentials. */ +export function ensureMessagingCredentialProviderProfile(input: { + readonly root: string; + readonly runOpenshell: EndpointlessProviderProfileRunner; +}): void { + const result = ensureEndpointlessProviderProfile({ + profileId: MESSAGING_CREDENTIAL_PROVIDER_TYPE, + inferenceCapable: false, + profilePath: messagingCredentialProviderProfilePath(input.root), + runOpenshell: input.runOpenshell, + }); + if (result.ok) return; + if (result.reason === "import-failed") { + throw new Error("Could not import the OpenShell messaging credential profile."); + } + if (result.reason === "export-failed") { + throw new Error( + `OpenShell provider profile '${MESSAGING_CREDENTIAL_PROVIDER_TYPE}' already exists but could not be exported for validation.`, + ); + } + throw new Error( + `OpenShell provider profile '${MESSAGING_CREDENTIAL_PROVIDER_TYPE}' already exists but does not match NemoClaw's endpointless messaging credential contract.`, + ); +} diff --git a/src/lib/onboard/checkpoint-replay.ts b/src/lib/onboard/checkpoint-replay.ts index 91b39659553..6c50775ee80 100644 --- a/src/lib/onboard/checkpoint-replay.ts +++ b/src/lib/onboard/checkpoint-replay.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../messaging/provider-profile"; import { getActiveChannelIdsFromPlan } from "../messaging/plan-validation"; import { isDecisionSelected } from "../state/onboard-checkpoint-decision"; import type { @@ -151,7 +152,8 @@ export function requiredMessagingProviderBindings( bindings.set(binding.providerName, { name: binding.providerName, type: - staticMessagingProviderTypeForChannel(binding.channelId, plan.agent, profiles) ?? "generic", + staticMessagingProviderTypeForChannel(binding.channelId, plan.agent, profiles) ?? + MESSAGING_CREDENTIAL_PROVIDER_TYPE, credentialEnv: binding.providerEnvKey, }); } diff --git a/src/lib/onboard/command-support.test.ts b/src/lib/onboard/command-support.test.ts index 8cfcf53efd6..98a4fbc121d 100644 --- a/src/lib/onboard/command-support.test.ts +++ b/src/lib/onboard/command-support.test.ts @@ -56,13 +56,13 @@ describe("buildOnboardFlags --events help", () => { }); describe("buildOnboardFlags temporary managed runtime gate", () => { - it("accepts the activation flag without advertising it in CLI help", () => { + it("keeps candidate activation hidden while allowing exact stock qualification catalogs", () => { const flags = buildOnboardFlags({ includeEvents: true }); expect(flags["temp-managed-runtime"].hidden).toBe(true); expect(flags["temp-managed-runtime"].description).toBeUndefined(); expect(flags["temp-managed-runtime-catalog"].hidden).toBe(true); - expect(flags["temp-managed-runtime-catalog"].dependsOn).toEqual(["temp-managed-runtime"]); + expect(flags["temp-managed-runtime-catalog"].dependsOn).toBeUndefined(); expect(flags.events.hidden).not.toBe(true); }); }); diff --git a/src/lib/onboard/command-support.ts b/src/lib/onboard/command-support.ts index 58fe403fae0..1507cc9795f 100644 --- a/src/lib/onboard/command-support.ts +++ b/src/lib/onboard/command-support.ts @@ -95,10 +95,7 @@ export type OnboardFlags = { export function buildOnboardFlags(options: { includeEvents?: boolean } = {}): Record { const flags = { "temp-managed-runtime": Flags.boolean({ hidden: true }), - "temp-managed-runtime-catalog": Flags.string({ - hidden: true, - dependsOn: ["temp-managed-runtime"], - }), + "temp-managed-runtime-catalog": Flags.string({ hidden: true }), "non-interactive": Flags.boolean({ description: "Run without interactive prompts" }), resume: Flags.boolean({ description: "Resume an interrupted onboarding session", diff --git a/src/lib/onboard/command.test.ts b/src/lib/onboard/command.test.ts index 3c42bcf5537..c5b90c2368f 100644 --- a/src/lib/onboard/command.test.ts +++ b/src/lib/onboard/command.test.ts @@ -304,6 +304,21 @@ describe("onboard command options", () => { }); }); + it("accepts an exact qualification catalog without enabling candidate activation", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-catalog-only-")); + const managedCatalogPath = path.join(tmpDir, "managed-catalog.json"); + fs.writeFileSync(managedCatalogPath, "{}\n"); + + try { + expect(resolve({ "temp-managed-runtime-catalog": managedCatalogPath })).toMatchObject({ + tempManagedRuntime: false, + tempManagedRuntimeCatalog: managedCatalogPath, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("uses explicit false/null defaults when flags are absent", () => { expect(resolve({})).toEqual({ tempManagedRuntime: false, diff --git a/src/lib/onboard/credential-provider-registration.ts b/src/lib/onboard/credential-provider-registration.ts index 3f4543f198d..1feaef2dd4d 100644 --- a/src/lib/onboard/credential-provider-registration.ts +++ b/src/lib/onboard/credential-provider-registration.ts @@ -174,7 +174,7 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg function upsertMessagingProviders( tokenDefs: MessagingTokenDef[], - options: { replaceExisting?: boolean } = {}, + options: { replaceExisting?: boolean; allowedSandboxes?: readonly string[] } = {}, runOpenshell: OpenshellCliHelpers["runOpenshell"] = deps.runOpenshell, ): string[] { ensureWebSearchProviderProfiles(tokenDefs, runOpenshell); diff --git a/src/lib/onboard/docker-gpu-patch-clone.test.ts b/src/lib/onboard/docker-gpu-patch-clone.test.ts index 28d43714b6d..ee360e1f276 100644 --- a/src/lib/onboard/docker-gpu-patch-clone.test.ts +++ b/src/lib/onboard/docker-gpu-patch-clone.test.ts @@ -274,7 +274,7 @@ describe("Docker GPU clone envelope", () => { expect(args).not.toContain("nofile=1024:1024"); }); - it("preserves each Docker attach stream independently", () => { + it("does not replay client attachment state into detached recreation", () => { const inspect = inspectFixture(); Object.assign(inspect.Config!, { AttachStdin: true, @@ -284,9 +284,7 @@ describe("Docker GPU clone envelope", () => { const args = buildDockerGpuCloneRunArgs(inspect, buildDockerGpuMode("startup-command")); - expect(args).toEqual( - expect.arrayContaining(["--attach", "stdin", "--attach", "stdout", "--attach", "stderr"]), - ); + expect(args).not.toContain("--attach"); }); it.each([2048, -1])("preserves the exact Docker PID limit %i", (pidsLimit) => { diff --git a/src/lib/onboard/docker-gpu-patch-clone.ts b/src/lib/onboard/docker-gpu-patch-clone.ts index 75e41f9f49e..616bfccab23 100644 --- a/src/lib/onboard/docker-gpu-patch-clone.ts +++ b/src/lib/onboard/docker-gpu-patch-clone.ts @@ -482,13 +482,6 @@ export function buildDockerGpuCloneRunArgs( pushStringFlag(args, "--workdir", config.WorkingDir); if (config.Tty) args.push("--tty"); if (config.OpenStdin) args.push("--interactive"); - for (const stream of [ - ...(config.AttachStdin ? ["stdin"] : []), - ...(config.AttachStdout ? ["stdout"] : []), - ...(config.AttachStderr ? ["stderr"] : []), - ]) { - args.push("--attach", stream); - } const sandboxCommand = openshellSandboxCommandEnvValue(options.openshellSandboxCommand); const omitOciImageUser = shouldOmitOpenShellOciImageUser( diff --git a/src/lib/onboard/docker-gpu-sandbox-create.ts b/src/lib/onboard/docker-gpu-sandbox-create.ts index cc4983bb01a..9e2eacdd07c 100644 --- a/src/lib/onboard/docker-gpu-sandbox-create.ts +++ b/src/lib/onboard/docker-gpu-sandbox-create.ts @@ -29,6 +29,11 @@ import { type RecreateGpuPatchFn, type RecreateStartupPatchFn, } from "./docker-startup-command-sandbox-create"; +import { ManagedBootstrapOwnerCleanupRequiredError } from "./managed-bootstrap/adapter"; +import type { + ManagedBootstrapNativeGpuFallbackRollbackOutcome, + ManagedBootstrapNativeGpuFallbackRollbackRequest, +} from "./managed-bootstrap/runtime-create"; import { findOpenShellDockerSandboxContainerIds } from "./openshell-docker-sandbox-containers"; export type { DockerGpuRoutePlan, SelectedDockerGpuRoute } from "./docker-gpu-route"; @@ -120,7 +125,9 @@ export type DockerGpuSandboxCreatePatch = { createFailureMessage: () => string | null; exitOnPatchError: () => Promise; attachManagedBootstrapCutover: (cutover: DockerManagedBootstrapDeferredCutover) => void; - rollbackManagedStartupAfterCreateFailure: () => Promise; + rollbackManagedStartupAfterCreateFailure: ( + request?: ManagedBootstrapNativeGpuFallbackRollbackRequest, + ) => Promise; ensureApplied: () => Promise; waitForSupervisorReconnectIfNeeded: () => void; /** @@ -316,9 +323,23 @@ export function createDockerGpuSandboxCreatePatch( managedBootstrapCutover = cutover; }, - async rollbackManagedStartupAfterCreateFailure() { + async rollbackManagedStartupAfterCreateFailure(request) { const rollbackError = await rollbackAfterFailure(); - if (!rollbackError) return; + if (!rollbackError) return request ? { kind: "rolled-back" } : undefined; + if ( + request?.ownerCleanupHandoff === "native-gpu-fallback-after-absent-attachment" && + options.route === "native" && + options.externalRecreation === true && + rollbackError instanceof ManagedBootstrapOwnerCleanupRequiredError && + rollbackError.sandboxName === options.sandboxName + ) { + return Object.freeze({ + kind: "openshell-owner-cleanup-required", + sandboxName: rollbackError.sandboxName, + sandboxId: rollbackError.sandboxId, + runtimeId: rollbackError.runtimeId, + }); + } onPatchFailureExit(options.sandboxName, rollbackError, { ...failureDiagnosticDeps, additionalSummaryLines: routeAdapter.additionalSummaryLines, @@ -327,6 +348,7 @@ export function createDockerGpuSandboxCreatePatch( rolledBack: false, }, }); + if (request) throw rollbackError; }, async ensureApplied() { diff --git a/src/lib/onboard/experimental/hermes-portable-build-context-files.ts b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts index d8a6ed7723d..d1318ec2147 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context-files.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts @@ -276,6 +276,7 @@ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [ { path: "src/lib/messaging/channels/slack/rendered-config-parser.ts", mode: "100644" }, { path: "src/lib/messaging/channels/slack/runtime/slack-channel-guard.ts", mode: "100644" }, { path: "src/lib/messaging/channels/slack/template-resolver.ts", mode: "100644" }, + { path: "src/lib/messaging/channels/teams/contract.ts", mode: "100644" }, { path: "src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.test.ts", mode: "100644", @@ -393,6 +394,7 @@ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [ { path: "src/lib/messaging/manifest/registry.ts", mode: "100644" }, { path: "src/lib/messaging/manifest/types.test.ts", mode: "100644" }, { path: "src/lib/messaging/manifest/types.ts", mode: "100644" }, + { path: "src/lib/messaging/managed-startup-placeholders.test.ts", mode: "100644" }, { path: "src/lib/messaging/managed-startup-placeholders.ts", mode: "100644" }, { path: "src/lib/messaging/persisted-placeholders.test.ts", mode: "100644" }, { path: "src/lib/messaging/persisted-placeholders.ts", mode: "100644" }, @@ -404,6 +406,8 @@ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [ { path: "src/lib/messaging/post-agent-install-selection.test.ts", mode: "100644" }, { path: "src/lib/messaging/post-agent-install-selection.ts", mode: "100644" }, { path: "src/lib/messaging/provider-placeholders.ts", mode: "100644" }, + { path: "src/lib/messaging/provider-profile.test.ts", mode: "100644" }, + { path: "src/lib/messaging/provider-profile.ts", mode: "100644" }, { path: "src/lib/messaging/README.md", mode: "100644" }, { path: "src/lib/messaging/utils.test.ts", mode: "100644" }, { path: "src/lib/messaging/utils.ts", mode: "100644" }, diff --git a/src/lib/onboard/experimental/hermes-portable-build-context.test.ts b/src/lib/onboard/experimental/hermes-portable-build-context.test.ts index 9cbfc5b0c80..2bb45151362 100644 --- a/src/lib/onboard/experimental/hermes-portable-build-context.test.ts +++ b/src/lib/onboard/experimental/hermes-portable-build-context.test.ts @@ -156,6 +156,11 @@ describe("Hermes portable staged build context", testTimeoutOptions(30_000), () path.join(first.buildContextPath, "src/lib/messaging/channels/wechat/contract.ts"), ), ).toBe(true); + expect( + fs.existsSync( + path.join(first.buildContextPath, "src/lib/messaging/channels/teams/contract.ts"), + ), + ).toBe(true); expect( fs.existsSync( path.join(first.buildContextPath, "src/lib/messaging/managed-startup-placeholders.ts"), diff --git a/src/lib/onboard/extra-placeholder-keys.test.ts b/src/lib/onboard/extra-placeholder-keys.test.ts index e1d8223eec5..88381260415 100644 --- a/src/lib/onboard/extra-placeholder-keys.test.ts +++ b/src/lib/onboard/extra-placeholder-keys.test.ts @@ -94,7 +94,7 @@ describe("parseExtraPlaceholderKeys", () => { // GITHUB_TOKEN, AWS_*, NPM_TOKEN, and the control env itself match the // upper-snake regex but do not extend any canonical channel envKey. The // parser rejects them so an operator cannot accidentally hand a host - // secret to the OpenShell generic provider gateway. + // secret to the OpenShell gateway as a messaging credential. const result = parseExtraPlaceholderKeys( [ "GITHUB_TOKEN", @@ -194,7 +194,7 @@ describe("registerExtraPlaceholderProviders", () => { } } - it("appends one generic-provider tokenDef per validated extra key with the operator-supplied token", () => { + it("appends one profile-backed tokenDef per validated extra key with the operator-supplied token (#9875)", () => { withEnv( { [EXTRA_PLACEHOLDER_KEYS_ENV]: "TELEGRAM_BOT_TOKEN_AGENT_A SLACK_BOT_TOKEN_AGENT_B", @@ -219,13 +219,13 @@ describe("registerExtraPlaceholderProviders", () => { name: "my-sandbox-extra-telegram-bot-token-agent-a", envKey: "TELEGRAM_BOT_TOKEN_AGENT_A", token: "telegram-token-A", - providerType: "generic", + providerType: "nemoclaw-mcp-v1", }, { name: "my-sandbox-extra-slack-bot-token-agent-b", envKey: "SLACK_BOT_TOKEN_AGENT_B", token: "slack-token-B", - providerType: "generic", + providerType: "nemoclaw-mcp-v1", }, ]); }, @@ -233,7 +233,7 @@ describe("registerExtraPlaceholderProviders", () => { }); it("registers a tokenDef with token=null when the operator forgot to export the credential", () => { - // The generic provider upsert in onboard/providers.ts already skips + // The messaging provider upsert in onboard/providers.ts already skips // null-token entries so the row is not registered with the OpenShell // gateway. The unit assertion here pins the contract that // registerExtraPlaceholderProviders never substitutes a placeholder value @@ -257,7 +257,7 @@ describe("registerExtraPlaceholderProviders", () => { name: "my-sandbox-extra-telegram-bot-token-agent-missing", envKey: "TELEGRAM_BOT_TOKEN_AGENT_MISSING", token: null, - providerType: "generic", + providerType: "nemoclaw-mcp-v1", }, ]); }, diff --git a/src/lib/onboard/extra-placeholder-keys.ts b/src/lib/onboard/extra-placeholder-keys.ts index 7cb28d7ed32..0f09bd38d22 100644 --- a/src/lib/onboard/extra-placeholder-keys.ts +++ b/src/lib/onboard/extra-placeholder-keys.ts @@ -3,6 +3,7 @@ import { getCredential, normalizeCredentialValue } from "../credentials/store"; import * as webSearch from "../inference/web-search"; +import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../messaging/provider-profile"; import { getChannelTokenKeys, listChannels } from "../sandbox/channels"; interface MessagingTokenDefShape { @@ -112,7 +113,7 @@ export function registerExtraPlaceholderProviders( name: `${sandboxName}-extra-${extraPlaceholderProviderSlug(envKey)}`, envKey, token, - providerType: "generic", + providerType: MESSAGING_CREDENTIAL_PROVIDER_TYPE, }); } return [...parsed.keys]; diff --git a/src/lib/onboard/gateway-provider-metadata.test.ts b/src/lib/onboard/gateway-provider-metadata.test.ts index 211b8a773d4..020a7873d46 100644 --- a/src/lib/onboard/gateway-provider-metadata.test.ts +++ b/src/lib/onboard/gateway-provider-metadata.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { + inspectGatewayCredentialOnlyProviderBinding, matchesGatewayCredentialOnlyProviderBinding, matchesGatewayProviderBinding, parseGatewayProviderMetadata, @@ -91,6 +92,44 @@ describe("gateway provider metadata", () => { ).toBe(false); }); + it("distinguishes exact, missing, incompatible, and indeterminate credential providers", () => { + const expected = { + name: "alpha-telegram-bridge", + type: "nemoclaw-mcp-v1", + credentialKey: "TELEGRAM_BOT_TOKEN", + }; + const exact = + "Name: alpha-telegram-bridge\nType: nemoclaw-mcp-v1\nCredential keys: TELEGRAM_BOT_TOKEN\nConfig keys: \n"; + + expect( + inspectGatewayCredentialOnlyProviderBinding(expected, () => ({ status: 0, stdout: exact })), + ).toEqual({ kind: "exact" }); + expect( + inspectGatewayCredentialOnlyProviderBinding(expected, () => ({ + status: 0, + stdout: exact.replace("Type: nemoclaw-mcp-v1", "Type: generic"), + })), + ).toEqual({ kind: "collision" }); + expect( + inspectGatewayCredentialOnlyProviderBinding(expected, () => ({ + status: 1, + stderr: + "Error: code: 'Some requested entity was not found', message: \"provider not found\"", + })), + ).toEqual({ kind: "missing" }); + expect( + inspectGatewayCredentialOnlyProviderBinding(expected, () => ({ + status: 1, + stderr: 'Error: status: Unavailable, message: "provider not found"', + })), + ).toEqual({ kind: "indeterminate" }); + expect( + inspectGatewayCredentialOnlyProviderBinding(expected, () => { + throw new Error("transport failure"); + }), + ).toEqual({ kind: "indeterminate" }); + }); + it("parses one complete ANSI-decorated provider identity", () => { expect(parseGatewayProviderMetadata(COMPLETE_OUTPUT)).toEqual({ name: "compatible-endpoint", @@ -229,4 +268,25 @@ describe("gateway provider metadata", () => { expect(readGatewayProviderMetadata("../compatible-endpoint", runOpenshell)).toBeNull(); expect(runOpenshell).not.toHaveBeenCalled(); }); + + it.each([ + [ + "exact absence", + { status: 1, stderr: "provider 'alpha-telegram-bridge' not found" }, + "missing", + ], + ["gateway failure", { status: 1, stderr: "gateway unavailable" }, "indeterminate"], + ["null status", { status: null, stderr: "transport closed" }, "indeterminate"], + ] as const)("classifies %s without authorizing a create (#9875)", (_label, result, kind) => { + expect( + inspectGatewayCredentialOnlyProviderBinding( + { + name: "alpha-telegram-bridge", + type: "nemoclaw-mcp-v1", + credentialKey: "TELEGRAM_BOT_TOKEN", + }, + () => result, + ), + ).toEqual({ kind }); + }); }); diff --git a/src/lib/onboard/gateway-provider-metadata.ts b/src/lib/onboard/gateway-provider-metadata.ts index 8ab4072f468..f5a7f20df92 100644 --- a/src/lib/onboard/gateway-provider-metadata.ts +++ b/src/lib/onboard/gateway-provider-metadata.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { reportsExactProviderNotFound } from "./extra-provider-diagnostic-parser"; + const MAX_PROVIDER_OUTPUT_BYTES = 16 * 1024; +const PROVIDER_PROBE_DIAGNOSTIC_LIMIT = 64 * 1024; +const PROVIDER_PROBE_TIMEOUT_MS = 5_000; const MAX_PROVIDER_NAME_LENGTH = 128; const MAX_PROVIDER_TYPE_LENGTH = 64; const MAX_PROVIDER_KEYS = 32; @@ -65,20 +69,31 @@ export function matchesGatewayCredentialOnlyProviderBinding( } type GatewayProviderCommandResult = { - status: number | null; - stdout?: string | Buffer | null; - stderr?: string | Buffer | null; + status?: number | null; + stdout?: unknown; + stderr?: unknown; + output?: unknown; + error?: unknown; + signal?: unknown; }; type GatewayProviderRunner = ( args: string[], options: { ignoreError: true; + maxBuffer?: number; suppressOutput: true; stdio: ["ignore", "pipe", "pipe"]; + timeout?: number; }, ) => GatewayProviderCommandResult; +export type GatewayCredentialOnlyProviderInspection = + | { readonly kind: "collision" } + | { readonly kind: "exact" } + | { readonly kind: "indeterminate" } + | { readonly kind: "missing" }; + type ProviderField = "Name" | "Type" | "Credential keys" | "Config keys"; const PROVIDER_FIELD_PATTERN = /^\s*(Name|Type|Credential keys|Config keys):\s*(.*?)\s*$/i; @@ -111,8 +126,18 @@ function parseProviderKeys(value: string): string[] | null { return keys; } -function commandStreamText(value: string | Buffer | null | undefined): string { - return Buffer.isBuffer(value) ? value.toString("utf8") : (value ?? ""); +function commandStreamText(value: unknown): string { + if (typeof value === "string") return value; + if (Buffer.isBuffer(value)) return value.toString("utf8"); + if (Array.isArray(value)) return value.map(commandStreamText).filter(Boolean).join("\n"); + return ""; +} + +function providerCommandOutput(result: GatewayProviderCommandResult): string { + const streams = [result.stderr, result.stdout] + .map(commandStreamText) + .filter((value) => value.length > 0); + return streams.length > 0 ? streams.join("\n") : commandStreamText(result.output); } function hasUnsafeRawProviderFieldValue(rawLine: string): boolean { @@ -170,6 +195,40 @@ export function parseGatewayProviderMetadata(output: string): GatewayProviderMet return { name, type, credentialKeys, configKeys }; } +/** Distinguish an exact credential-only binding from absence and lookup failure. */ +export function inspectGatewayCredentialOnlyProviderBinding( + expected: GatewayCredentialOnlyProviderBinding, + runOpenshell: GatewayProviderRunner, +): GatewayCredentialOnlyProviderInspection { + let result: GatewayProviderCommandResult; + try { + result = runOpenshell(["provider", "get", expected.name], { + ignoreError: true, + maxBuffer: PROVIDER_PROBE_DIAGNOSTIC_LIMIT, + suppressOutput: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: PROVIDER_PROBE_TIMEOUT_MS, + }); + } catch { + return { kind: "indeterminate" }; + } + + const output = providerCommandOutput(result); + if (result.error || result.signal || result.status !== 0) { + return !result.error && + !result.signal && + result.status === 1 && + reportsExactProviderNotFound(output, expected.name, PROVIDER_PROBE_DIAGNOSTIC_LIMIT) + ? { kind: "missing" } + : { kind: "indeterminate" }; + } + + const metadata = parseGatewayProviderMetadata(output); + return matchesGatewayCredentialOnlyProviderBinding(metadata, expected) + ? { kind: "exact" } + : { kind: "collision" }; +} + /** Read one exact provider identity without reading or exporting credential values. */ export function readGatewayProviderMetadata( name: string, diff --git a/src/lib/onboard/initial-policy-real-policy.test.ts b/src/lib/onboard/initial-policy-real-policy.test.ts index f8ffdedad15..1364785211e 100644 --- a/src/lib/onboard/initial-policy-real-policy.test.ts +++ b/src/lib/onboard/initial-policy-real-policy.test.ts @@ -8,7 +8,16 @@ import { afterEach, describe, expect, it } from "vitest"; import YAML from "yaml"; import { SHIPPED_MANAGED_IMAGE_AGENTS } from "./managed-image/contract"; -import { MANAGED_STARTUP_MERGED_CA_FILE } from "./managed-startup/image-runtime"; +import { + MANAGED_STARTUP_COMPLETION_FILE, + MANAGED_STARTUP_MERGED_CA_FILE, + MANAGED_STARTUP_RUNTIME_ENV_FILE, +} from "./managed-startup/image-runtime"; +import { + MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY, + MANAGED_STARTUP_SHARED_ROLLBACK_RECEIPT_DIRECTORY, + MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY, +} from "./managed-startup/shared-state-transaction"; import { prepareInitialSandboxCreatePolicy } from "./initial-policy"; type PolicyRule = { @@ -100,8 +109,23 @@ describe("initial sandbox policy real preset merge", () => { const shippingPolicyCases = managedImagePolicyCases.filter( ({ agent }) => agent !== "langchain-deepagents-code", ); - - it("covers the complete shipped managed startup CA policy matrix", () => { + const managedStartupReadOnlyPaths = [ + { path: MANAGED_STARTUP_MERGED_CA_FILE, issue: "#9360", purpose: "CA bundle" }, + { + path: MANAGED_STARTUP_RUNTIME_ENV_FILE, + issue: "#9357", + purpose: "runtime environment", + }, + ] as const; + const protectedManagedStartupPaths = [ + MANAGED_STARTUP_COMPLETION_FILE, + "/run/nemoclaw/openclaw-config-guard", + MANAGED_STARTUP_SHARED_ROLLBACK_RECEIPT_DIRECTORY, + MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY, + MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY, + ] as const; + + it("covers the complete shipped managed startup trust policy matrix", () => { const policyIdentities = managedImagePolicyCases.map( ({ path: policyPath, agent }) => `${agent}:${policyPath.join("/")}`, ); @@ -109,11 +133,19 @@ describe("initial sandbox policy real preset merge", () => { expect(Object.keys(managedImagePolicyPathsByAgent)).toEqual([...SHIPPED_MANAGED_IMAGE_AGENTS]); expect(policyIdentities).toHaveLength(6); expect(new Set(policyIdentities).size).toBe(policyIdentities.length); + expect(managedStartupReadOnlyPaths.map(({ path: trustedPath }) => trustedPath)).toEqual([ + MANAGED_STARTUP_MERGED_CA_FILE, + MANAGED_STARTUP_RUNTIME_ENV_FILE, + ]); }); - it.each(managedImagePolicyCases)( - "grants $agent policy $path exact read-only access to the managed startup CA bundle (#9360)", - (policyCase) => { + it.each( + managedImagePolicyCases.flatMap((policyCase) => + managedStartupReadOnlyPaths.map((trustedPath) => ({ policyCase, trustedPath })), + ), + )( + "grants $policyCase.agent policy $policyCase.path exact read-only access to the managed startup $trustedPath.purpose ($trustedPath.issue)", + ({ policyCase, trustedPath }) => { const prepared = prepareInitialSandboxCreatePolicy(repoPath(...policyCase.path), [], { agentName: policyCase.agent, }); @@ -122,23 +154,48 @@ describe("initial sandbox policy real preset merge", () => { const readWrite = policy.filesystem_policy?.read_write ?? []; const normalizedReadOnly = readOnly.map(normalizeFilesystemPolicyPath); const normalizedReadWrite = readWrite.map(normalizeFilesystemPolicyPath); - const managedCaAncestors = filesystemPolicyAncestors(MANAGED_STARTUP_MERGED_CA_FILE); + const trustedPathAncestors = filesystemPolicyAncestors(trustedPath.path); - expect(readOnly, policyCase.path.join("/")).toContain(MANAGED_STARTUP_MERGED_CA_FILE); - expect(normalizedReadWrite, policyCase.path.join("/")).not.toContain( - MANAGED_STARTUP_MERGED_CA_FILE, - ); + expect(readOnly, policyCase.path.join("/")).toContain(trustedPath.path); + expect(normalizedReadWrite, policyCase.path.join("/")).not.toContain(trustedPath.path); expect( - normalizedReadOnly.filter((candidate) => managedCaAncestors.includes(candidate)), + normalizedReadOnly.filter((candidate) => trustedPathAncestors.includes(candidate)), policyCase.path.join("/"), ).toEqual([]); expect( - normalizedReadWrite.filter((candidate) => managedCaAncestors.includes(candidate)), + normalizedReadWrite.filter((candidate) => trustedPathAncestors.includes(candidate)), policyCase.path.join("/"), ).toEqual([]); }, ); + it.each( + managedImagePolicyCases.flatMap((policyCase) => + protectedManagedStartupPaths.map((protectedPath) => ({ policyCase, protectedPath })), + ), + )( + "keeps $protectedPath inaccessible in $policyCase.agent policy $policyCase.path (#9357)", + ({ policyCase, protectedPath }) => { + const prepared = prepareInitialSandboxCreatePolicy(repoPath(...policyCase.path), [], { + agentName: policyCase.agent, + }); + const policy = readPreparedPolicy(prepared); + const grantedPaths = [ + ...(policy.filesystem_policy?.read_only ?? []), + ...(policy.filesystem_policy?.read_write ?? []), + ].map(normalizeFilesystemPolicyPath); + const exposingGrants = new Set([ + ...filesystemPolicyAncestors(protectedPath), + normalizeFilesystemPolicyPath(protectedPath), + ]); + + expect( + grantedPaths.filter((candidate) => exposingGrants.has(candidate)), + `${policyCase.path.join("/")} exposes ${protectedPath}`, + ).toEqual([]); + }, + ); + it.each([ { path: ["nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"], diff --git a/src/lib/onboard/lifecycle-contracts.md b/src/lib/onboard/lifecycle-contracts.md index f8e1c1dcd5c..071d64f2e13 100644 --- a/src/lib/onboard/lifecycle-contracts.md +++ b/src/lib/onboard/lifecycle-contracts.md @@ -121,7 +121,7 @@ runtime mutation | **`--fresh` onboard** — `resolveOnboardEntryOptions`, `prepareFreshSession`, `createBaseImageResolutionContext` | Current flags/environment/prompts replace resumable intent. `--fresh` disables auto-resume and forces base-image resolution; it does not prove that the selected sandbox name is unused. | The first destructive effect is local: the prior onboard session is cleared before a new session is saved. A matching live sandbox can later reuse or recreate through the normal sandbox decision; `--fresh` does not itself delete it. | The new session and machine snapshot replace the old resume checkpoint. Credential and effect boundaries then match new onboard or live recreate. | The discarded resume checkpoint is not restored on later failure. Covered by `entry-options.test.ts`, `session-bootstrap.test.ts`, and base-image resolution tests. | | **Resume, re-onboard, or recreate** — `onboard()`, `prepareOnboardSession`, `decideSandboxResume`, live-sandbox handling in `createSandbox` | For `--resume`, the recorded session is authoritative and conflicting current name/provider/model/image/tool-disclosure hints are rejected. A new re-onboard run takes current flags, environment, and prompts as intent while registry/gateway state provides drift evidence. The machine resolves a complete secret-free create intent, including policy, messaging/provider, GPU, resource, disabled-channel, and agent inputs, before repair/removal or live recreation. | Ordinary live recreation conditionally backs up before provider cleanup, **delete**, and image removal. The recreate journal preserves the source registry row after deletion. Replacement registration commits the new row after readiness and validation. A selected pre-upgrade backup suppresses a new one; an explicit override permits recreation without backup. Resume registry removal and `repair-and-recreate` occur only after complete intent validation. Temporary policy/build artifacts remain materialization effects after the delete boundary. | Resume continues the recorded session/machine snapshot; non-resume re-onboard writes a new session first. OpenClaw records completed sandbox name, web search, messaging, and resource choices with explicit progress markers, including explicit `null` choices, while the complete create intent stays process-local and is not persisted or emitted. Raw credential values remain outside the session. A missing process value can be rebound only when the same OpenClaw session recorded successfully registering that provider and its live provider name, provider type, and credential key still match; otherwise interactive resume requests it again and non-interactive resume exits with environment-variable guidance. Credentials are checked before mutation and again immediately before materialization. | A failed replacement keeps the source registry row. Restore failures warn and can still publish the replacement; managed-DCode live-selection failure leaves a running, unregistered sandbox with manual-delete guidance. Checkpoint replay reuses an exact live sandbox after an interrupted create and backfills missing create/register receipts. Cancel rollback is not armed and there is no rebuild-style receipt rollback. Coverage: transition traces, create-intent characterization, checkpoint replay and resume guards, and sandbox-handler crash recovery. Gaps: early backup asymmetry and no rebuild-style cross-effect rollback. | | **Rebuild or installer-driven upgrade** — `rebuildSandbox` in `rebuild-pipeline.ts`; `upgradeSandboxes` | Registry state is authoritative. A matching session may fill guarded legacy gaps only when its selection agrees; an unrelated/global session is never used. Ambient provider/model selection is quarantined by `isolateAmbientRecreateEnv`, apart from narrowly scoped legacy recovery. Legacy and custom-image rebuilds retain and fingerprint a prepared build context. Managed-image rebuilds instead stage an immutable image and startup-profile handoff, skip Dockerfile image preflight, and revalidate provider-bound workload authority before each deletion boundary. | Consent persistence, target-gateway selection/recovery, and target-preflight registry updates can precede disposable image build/probes. Backup is the first durable recovery checkpoint when available. Shields unlock, MCP detach/scrub, and NIM stop are destructive in-place effects before the **sandbox delete** boundary. Legacy and custom-image paths recheck prepared context and mutation-edge conditions before delete. Managed-image paths revalidate the exact provider-bound handoff before delete. | Durable checkpoints are the backup/recovery manifest when one exists and the rewritten recreate session; stale recovery can reach deletion without a manifest, making that session its first new durable checkpoint. Rollback receipts/snapshots are process-local. Credential metadata comes from the target or guarded fallback; raw credentials/providers are checked against current process/gateway state, while prepared installer recovery may reconstruct a missing gateway provider from a validated host credential. | In-process rollback best-effort restores registry/MCP retry metadata, but process death after non-MCP delete can still lose it. The inner onboarding consumes the exact managed-workload handoff or selects the legacy resource profile after deletion. Covered by rebuild, managed-workload authority, image-preflight, DCode, and messaging tests. Gaps: health-before-delete and atomic swap. Closed issue #5801 records the original gap; #6835 fixed only the printed recovery path. | -| **Managed-image onboarding qualification (internal and unsupported)** — hidden managed-workload handoff in `onboard-orchestration.ts` | The hidden qualification path validates one complete all-agent catalog, immutable release and platform contracts, and exact provider capabilities before selecting a managed image. Ordinary onboarding disables this selection and keeps the Dockerfile path. | The internal path skips Dockerfile build materialization, creates provider-bound bootstrap authority for the immutable image and startup profile, launches that exact workload, and registers the managed-workload receipt only after readiness. No supported public interface selects this branch. | Catalog contracts, bootstrap authority, and workload receipts are secret-free and identity-bound. Raw provider credentials retain their existing process and gateway boundaries. | Preparation and provider failures stop before registration; provider-owned bootstrap rollback and durable recovery own partial activation. Catalog, bootstrap, managed-image activation, and protected-runtime tests cover the hidden branch. Product activation remains gated by epic [#7744](https://github.com/NVIDIA/NemoClaw/issues/7744). | +| **Stock Docker-driver managed-image onboarding** — managed-workload selection in `onboard-orchestration.ts` | Ordinary onboarding through the OpenShell Docker driver validates one complete all-agent catalog, immutable release and platform contracts, and exact selected-provider capabilities before selecting OpenClaw, Hermes, or LangChain Deep Agents Code. Portable onboarding, non-managed agents, and explicit `--from` custom images retain their legacy or custom workload paths. | The managed path skips Dockerfile build materialization, creates provider-bound bootstrap authority for the immutable image and startup profile, launches that exact workload, and registers the managed-workload receipt only after readiness. | Catalog contracts, bootstrap authority, and workload receipts are secret-free and identity-bound. Raw provider credentials retain their existing process and gateway boundaries. | Preparation and provider failures stop before registration; provider-owned bootstrap rollback and durable recovery own partial activation. Catalog, bootstrap, managed-image activation, and protected-runtime tests cover the shipped Docker-driver path. Native Podman remains outside the production provider registry and supported surface. | | **Managed snapshot clone handoff and provider transaction (internal and dormant)** — `prepareManagedWorkloadCloneHandoff`; `prepareManagedCloneProviderTransaction` | The current source registry row owns mutable operator intent; the selected snapshot owns immutable managed-workload and provider-runtime history. Handoff preparation proves the selected runtime provider and its `clone` capability, exact current registry generation and live-identity fingerprint, snapshot/source workload equivalence, snapshot runtime generation, and the state layer's selected-manifest/payload digest. It rebinds the secret-free startup profile, messaging intent, dashboard identity, and provider-owned contributions for OpenClaw, Hermes, or DCode without a central Podman-specific switch. Provider preparation then resolves active application bindings plus provider-contributed bindings, treating a live exact provider as reusable only when the destination registry independently proves that same logical binding. | The handoff and provider plan are inert. The internal materializer can create only bindings proven absent at preflight; it never updates or deletes an existing destination-owned provider. Immediately before each create it revalidates the source and optional destination registry rows plus the exact `SnapshotRestoreAuthority`. Production snapshot restore does not invoke this transaction and continues to reject cross-sandbox managed-image restore through `rejectManagedSnapshotCloneUntilRebind`; no user-visible clone support is advertised. | Both plans are deeply frozen and secret-free. A successful create produces an exact process-local ownership receipt; a non-zero create reconciled to an exact provider remains ambiguous and unowned. The receipt ledger remembers completed cleanup so a repeated cleanup cannot delete a later same-name provider. Raw credentials exist only in the explicit apply environment and one OpenShell child environment. | Failure rolls back only providers confirmed created by the exact in-process receipt, preserves collisions and ambiguous creates, reports incomplete cleanup for retry, and never rewrites a reused provider. `src/lib/onboard/managed-workload-clone-handoff.test.ts`, `src/lib/onboard/managed-startup-clone-rebinder.test.ts`, and `src/lib/actions/sandbox/snapshot-managed-clone-handoff-dormancy.test.ts` cover the all-agent, Docker/MXC-style provider, canonical-name, and fail-closed boundaries; provider transaction tests cover race, force-replace, disappearing-credential, rollback, and idempotent cleanup. This PR intentionally covers only the dormant contract. Epic [#7744](https://github.com/NVIDIA/NemoClaw/issues/7744) tracks destination creation/bootstrap, filesystem mutation-edge invocation, Hermes broker activation, durable recovery, protected E2E, and user-visible activation. | | **Channel add/remove/start/stop** — `addSandboxChannel`, `removeSandboxChannel`, `sandboxChannelsSetEnabled` in `policy-channel.ts` | Add compiles and merges a manifest-derived channel delta with `MessagingWorkflowPlanner`. Start, stop, and remove transform the registry plan and rehydrate executable render/build/runtime/forward details from current manifests. | Token-backed add can mutate gateway credentials before policy and plan persistence; QR/in-sandbox-auth add skips that credential upsert. Start persists the enabled plan before policy; stop persists the disabled plan before the rebuild prompt. Remove clears QR-backed durable state when applicable, detaches gateway/bridge state, removes policy, then persists the plan. A queued rebuild has a separate delete boundary. | The compact registry messaging plan is authoritative; render/build/runtime/state/health entries and nested host-forward details are rehydrated rather than persisted. Session policy-preset sync is best-effort, and channel mutations do not rewrite `Session.messagingPlan`. Raw tokens stay in process/gateway bindings. | `rollbackChannelAdd`, re-disable after failed start, and fail-closed QR-state cleanup provide partial compensation. Covered by `policy-channel*.test.ts`, `workflow-planner.test.ts`, and channel integration tests. Gaps: channel add has a separate `--force` conflict policy; add/remove effects can precede plan persistence, and persistence failures are not fully rolled back. | | **Provider, model, or credential-binding change** — `runInferenceSet` | CLI intent plus registry/session metadata. Target resolution and OpenShell preparation occur before locking. The target is re-resolved in the mutating phase under the sandbox lifecycle and timer-bound shields locks; that phase validates provider/model syntax, selected agent, shields state, and local reachability before the first write. | First mutation is the gateway route, then a minimal registry write, API-family/config resolution, registry refresh, best-effort config/hash sync, matching-session update, and audit. An OpenClaw API-family change can then restart the managed gateway after the shields lock is released but while the outer sandbox lock remains held. No sandbox deletion. | Registry and matching session store logical provider/model/credential-environment metadata. Audit records the action, sandbox, and reason rather than credentials; raw values remain gateway-bound. | Forward-only; no rollback. `rebuild` is the repair path for degraded state. Covered by `inference-set*.test.ts`. Gap: several stores can diverge after a mid-sequence failure. | @@ -184,18 +184,10 @@ Provider inputs are detached and deeply frozen at the extension boundary, and ce Docker lifecycle inspection and GPU inspection remain inside the Docker provider adapter. The provider-neutral receipt can represent another provider, including an MXC-style implementation, without adding provider switches to snapshot or rebuild orchestration. -Legacy and custom-image snapshots retain their state-only backup and restore path. The managed -authority path may become the default for managed images only after the -[incremental runtime epic](https://github.com/NVIDIA/NemoClaw/issues/7744) completes create -finalization, clone/rebind, recovery, and activation for every supported agent with authority proven -before mutation. Any later consolidation must preserve legacy and custom-image restore parity. -This contract does not activate another runtime provider or managed-image onboarding path. -Ordinary onboard recreation and create finalization remain deferred under -[#7744](https://github.com/NVIDIA/NemoClaw/issues/7744) because the replacement target is not -registered when that restore currently runs; the raw state layer rejects a managed manifest unless -both exact content authority and a runtime-validation fence are present. Cross-provider clone and -rebind, durable interrupted-restore recovery, ordinary recreate integration, and user-visible -runtime activation are separately reviewable units tracked by that epic. +Legacy and custom-image snapshots retain their state-only backup and restore path. +The managed authority path backs ordinary Docker onboarding and recreation for the shipped managed agents without activating another runtime provider. +The raw state layer still rejects a managed manifest unless both exact content authority and a runtime-validation fence are present. +Cross-provider clone and rebind, durable interrupted-restore recovery, and provider expansion remain separately reviewable units tracked by the [incremental runtime epic](https://github.com/NVIDIA/NemoClaw/issues/7744). If provider proof fails after filesystem restoration, NemoClaw reports that state changed and requires the operator to retry the exact snapshot after the runtime stabilizes. ## Dormant Podman managed bootstrap authority diff --git a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts index 439a4824b7c..119fed87687 100644 --- a/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-checkpoint-crash-recovery.test.ts @@ -88,7 +88,12 @@ function fakeGatewayRunOpenshell() { ].join("\n"), stderr: "", } - : { status: 1, stdout: "", stderr: "not found" }; + : { + status: 1, + stdout: "", + stderr: + "Error: code: 'Some requested entity was not found', message: \"provider not found\"", + }; }; const handleCreate = (args: string[]): StubbedRunOpenshellResult => { @@ -824,6 +829,7 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { name: "my-assistant-discord-bridge", envKey: "DISCORD_BOT_TOKEN", token: "discord-secret", + providerType: "nemoclaw-mcp-v1", }, ], true, @@ -861,7 +867,11 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { const resumedSession = getSession(); expect(resumedSession.checkpoint?.effectGroups.messaging_providers).toBeDefined(); expect(resumedSession.checkpoint?.bindings.registeredProviders).toEqual([ - { name: "my-assistant-discord-bridge", type: "generic", credentialEnv: "DISCORD_BOT_TOKEN" }, + { + name: "my-assistant-discord-bridge", + type: "nemoclaw-mcp-v1", + credentialEnv: "DISCORD_BOT_TOKEN", + }, ]); }); @@ -899,7 +909,7 @@ describe("sandbox crash-recovery replay (#5961, #6228)", () => { expect(stageSandboxCredentialProviders).toHaveBeenCalledTimes(2); expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( "my-assistant-discord-bridge", - "generic", + "nemoclaw-mcp-v1", "DISCORD_BOT_TOKEN", ); expect(getSession().checkpoint?.effectGroups.messaging_providers).toBeUndefined(); diff --git a/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts b/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts index 937a2605cc8..9ec4f8cad8f 100644 --- a/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-create-intent-boundary.test.ts @@ -325,7 +325,7 @@ describe("sandbox create intent machine boundary", () => { (name: string, type: string, credentialEnvName: string) => (name === "tm-brave-search" && type === "brave" && credentialEnvName === "BRAVE_API_KEY") || (name === "tm-telegram-bridge" && - type === "generic" && + type === "nemoclaw-mcp-v1" && credentialEnvName === "TELEGRAM_BOT_TOKEN"), ); const stageSandboxCredentialProviders = vi @@ -337,7 +337,11 @@ describe("sandbox create intent machine boundary", () => { .mockImplementationOnce(async () => { durableSession.stagedCredentialProviders.push("tm-telegram-bridge"); return [ - { name: "tm-telegram-bridge", type: "generic", credentialEnv: "TELEGRAM_BOT_TOKEN" }, + { + name: "tm-telegram-bridge", + type: "nemoclaw-mcp-v1", + credentialEnv: "TELEGRAM_BOT_TOKEN", + }, ]; }) .mockResolvedValue([]); @@ -405,7 +409,7 @@ describe("sandbox create intent machine boundary", () => { requiredBindings: [ { name: "tm-telegram-bridge", - type: "generic", + type: "nemoclaw-mcp-v1", credentialEnv: "TELEGRAM_BOT_TOKEN", }, ], @@ -436,7 +440,7 @@ describe("sandbox create intent machine boundary", () => { ); expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( "tm-telegram-bridge", - "generic", + "nemoclaw-mcp-v1", "TELEGRAM_BOT_TOKEN", ); expect(calls.promptName).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 7daeac87043..5cf50806638 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -762,7 +762,7 @@ describe("reconcileSandboxMessaging plan authority", () => { expect(deps.providerMatchesGatewayCredential).toHaveBeenCalledWith( "alpha-slack-bridge", - "generic", + "nemoclaw-mcp-v1", "SLACK_BOT_TOKEN", ); expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); @@ -1120,7 +1120,7 @@ describe("reconcileSandboxMessaging completed checkpoint credentials", () => { expect(deps.providerMatchesGatewayCredential).toHaveBeenCalledWith( "alpha-telegram-bridge", - "generic", + "nemoclaw-mcp-v1", "TELEGRAM_BOT_TOKEN", ); expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); @@ -1142,7 +1142,7 @@ describe("reconcileSandboxMessaging completed checkpoint credentials", () => { expect(deps.providerMatchesGatewayCredential).toHaveBeenCalledWith( "alpha-telegram-bridge", - "generic", + "nemoclaw-mcp-v1", "TELEGRAM_BOT_TOKEN", ); expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index 317fb27857e..d179382d723 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -11,6 +11,7 @@ import { } from "../../../messaging"; import { mergeSandboxMessagingPlans } from "../../../messaging/applier/host-state-applier"; import type { MessagingAgentId, SandboxMessagingPlan } from "../../../messaging/manifest"; +import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../../../messaging/provider-profile"; import { type RegistryMessagingAuthority, resolveMessagingPlanAuthority, @@ -526,7 +527,8 @@ function missingCredentialNeedsValidation( if (validateMissingCredentials && !stagedProviderNames.has(binding.providerName)) return true; const providerMatches = deps.providerMatchesGatewayCredential( binding.providerName, - staticMessagingProviderTypeForChannel(binding.channelId, agent) ?? "generic", + staticMessagingProviderTypeForChannel(binding.channelId, agent) ?? + MESSAGING_CREDENTIAL_PROVIDER_TYPE, binding.providerEnvKey, ); return validateMissingCredentials && !providerMatches; diff --git a/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts b/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts index 9aa05a3c4e9..bec9781bc79 100644 --- a/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-provider-effect-replay.test.ts @@ -44,12 +44,12 @@ describe("handleSandboxState provider effect replay", () => { const slackAppToken = "xapp-current-token"; const slackBotBinding = { name: "my-assistant-slack-bridge", - type: "generic", + type: "nemoclaw-mcp-v1", credentialEnv: "SLACK_BOT_TOKEN", }; const slackAppBinding = { name: "my-assistant-slack-app", - type: "generic", + type: "nemoclaw-mcp-v1", credentialEnv: "SLACK_APP_TOKEN", }; const slackProviderBindings = [slackBotBinding, slackAppBinding]; @@ -80,7 +80,7 @@ describe("handleSandboxState provider effect replay", () => { }; const telegramBinding = { name: "my-assistant-telegram-bridge", - type: "generic", + type: "nemoclaw-mcp-v1", credentialEnv: "TELEGRAM_BOT_TOKEN", }; const session = createSession({ @@ -257,7 +257,7 @@ describe("handleSandboxState provider effect replay", () => { }; const oldMessagingBinding = { name: "my-assistant-telegram-bridge", - type: "generic", + type: "nemoclaw-mcp-v1", credentialEnv: "TELEGRAM_BOT_TOKEN", }; const session = createSession({ sandboxName: "my-assistant" }); @@ -404,7 +404,7 @@ describe("handleSandboxState provider effect replay", () => { }; const messagingBinding = { name: "my-assistant-telegram-bridge", - type: "generic", + type: "nemoclaw-mcp-v1", credentialEnv: "TELEGRAM_BOT_TOKEN", }; const telegramToken = "telegram-current-token"; diff --git a/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts b/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts index 5d6490080d3..4573cea2809 100644 --- a/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-recreate-journal.test.ts @@ -282,6 +282,7 @@ it("carries filtered presets through post-delete onboard resume", async () => { const createIntent = args.at(-1); expect(createIntent).toMatchObject({ recreate: true, + recreateJournalTargetIntentFingerprint: targetIntentFingerprint, rebuildPolicyPresets: ["github"], resolved: { policy: { options: { additionalPresets: ["github"] } }, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 5871fbbb89c..c81c482e04a 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -591,6 +591,7 @@ describe("handleSandboxState", () => { getSandboxRegistryEntry: () => ({ name: "saved", pendingRouteReservation: true, + reservationSessionId: session.sessionId, provider: "provider", model: "model", endpointUrl: null, @@ -614,6 +615,7 @@ describe("handleSandboxState", () => { expect(calls.createSandbox).not.toHaveBeenCalled(); expect(calls.updateSandbox).toHaveBeenCalledWith("saved", { pendingRouteReservation: undefined, + reservationSessionId: undefined, }); expect(calls.skipped).toHaveBeenCalledWith("sandbox", "saved"); expect(recordStateSkipped).toHaveBeenCalledWith("sandbox", { diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 4150fc3ce29..7039dd650b4 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -1160,6 +1160,7 @@ class SandboxStateFlow< if (state.sandboxName) { this.deps.updateSandboxRegistry(state.sandboxName, { pendingRouteReservation: undefined, + reservationSessionId: undefined, }); } this.deps.skippedStepMessage("sandbox", state.sandboxName); @@ -1648,6 +1649,8 @@ class SandboxStateFlow< ...(this.options.rebuildPreservedEnv ? { rebuildPreservedEnv: this.options.rebuildPreservedEnv } : {}), + recreateJournalTargetIntentFingerprint: + this.options.recreateJournalTargetIntentFingerprint ?? undefined, ...rebuildPolicyPresetSelection, extraProviders, }; diff --git a/src/lib/onboard/managed-bootstrap/README.md b/src/lib/onboard/managed-bootstrap/README.md index 22d288ecd40..5255026a8ed 100644 --- a/src/lib/onboard/managed-bootstrap/README.md +++ b/src/lib/onboard/managed-bootstrap/README.md @@ -4,13 +4,13 @@ # Managed bootstrap protocol This directory defines the driver-neutral bootstrap transaction, the Docker -implementation registered for managed-image qualification and rebuild handoffs, -and a dormant Podman candidate. Ordinary onboarding continues to use the -Dockerfile path unless an internal managed-image qualification or rebuild handoff -selects an immutable managed image. Podman remains absent from the production -provider registry, and its bootstrap surface remains unsupported. Do not -advertise either qualification path as a supported surface until its product -activation gate is accepted. +implementation registered for stock managed-image onboarding and rebuild +handoffs, and a dormant Podman candidate. Ordinary OpenShell Docker-driver onboarding for +the shipped OpenClaw, Hermes, and LangChain Deep Agents Code agents selects an +immutable managed image. Portable onboarding, agents without a managed-image +contract, and explicit `--from` custom Dockerfiles retain their existing +workload paths. Podman remains absent from the production provider registry, and +its bootstrap surface remains unsupported. The protocol binds one random bootstrap identity to: @@ -73,10 +73,11 @@ policy. The Docker-specific layers define a private, monotonic cutover journal, a canonical launch-spec normalizer, and an injectable provider create lifecycle. -The production Docker runtime bundle registers this surface for internal -managed-image qualification and rebuild handoffs. Ordinary onboarding does not -select it. The shared finalization surface extends rollback ownership for the -existing Docker compatibility and startup recreation paths. +The production Docker runtime bundle registers this surface for stock +managed-image onboarding of the shipped agents and for managed rebuild +handoffs. Portable onboarding, non-managed agents, and explicit custom +Dockerfiles do not select it. The shared finalization surface extends rollback +ownership for the existing Docker compatibility and startup recreation paths. The Docker adapter creates and validates a stopped replacement under an identity-derived staging name while the original remains running. It stages the @@ -122,7 +123,7 @@ root-apply envelope, starts the exact replacement, and authenticates the image-owned completion for OpenClaw, Hermes, or LangChain Deep Agents Code. The watcher remains stopped and the journal remains authoritative throughout. These modules are intentionally absent from the production provider registry and -cannot be selected by the hidden managed-image qualification path. Unit tests +cannot be selected by the stock Docker managed-image path. Unit tests exercise the dormant Podman bootstrap components in isolation. Later slices must add persisted engine recovery, GPU and local inference, installer coverage, protected E2E qualification, and accepted product activation. @@ -191,22 +192,21 @@ When recovery reports one of these records: identity-checked retirement path. Until that path ships, use a different sandbox name rather than deleting durable authority. -Supported activation must include the identity-checked retirement path and -protected recovery qualification. Current hidden Docker paths cannot create -schema 1 or schema 2 legacy records, and the Podman candidate remains inert. -Neither path is a supported product surface. +Any future support for retiring schema 1 or schema 2 legacy records must include +the identity-checked retirement path and protected recovery qualification. The +stock Docker managed-image path cannot create these legacy records, and the +dormant Podman candidate remains unsupported. ## Architectural disposition The runtime-provider bundle is the only bootstrap registration boundary. The production Docker bundle registers its create routing, replacement construction, native-to-compatibility fallback evidence, and deferred commit or rollback for -internal managed-image qualification and rebuild handoffs. Central onboarding -accepts that provider-neutral surface without a Docker or Podman selection -branch. Ordinary onboarding continues to use the Dockerfile path. Tests register -an MXC-style surface through the same bundle, render held launches for OpenClaw, -Hermes, and LangChain Deep Agents Code, and exercise recovery phases across all -three agents. +stock managed-image onboarding and rebuild handoffs. Central onboarding accepts +that provider-neutral surface without a Docker or Podman selection branch. +Ordinary OpenShell Docker-driver onboarding selects it for the shipped OpenClaw, Hermes, +and LangChain Deep Agents Code agents. Tests register an MXC-style surface +through the same bundle and exercise recovery phases across all three agents. The coordinator remains the driver-neutral transaction authority: its receipt shapes, normalization, state transitions, and rollback proofs form one cohesive @@ -221,8 +221,8 @@ production Docker registration and an MXC-style bootstrap surface through the same provider bundle contract. The native entrypoint and composed managed-bootstrap image runtime are compiled -and packaged in every managed agent image. Internal Docker qualification and -rebuild handoffs can select them; ordinary onboarding cannot. The image runtime +and packaged in every managed agent image. Stock OpenShell Docker-driver onboarding and +managed rebuild handoffs select them for the shipped agents. The image runtime composes the neutral managed-startup APIs with modes that consume the protected bootstrap envelope, bind shared-state authority to the exact attempt, publish an identity-bound @@ -256,15 +256,16 @@ environment checks. The dependency direction is one-way: this managed-bootstrap composition imports managed-startup, while managed-startup does not import managed-bootstrap. The production Docker provider imports the provider-neutral create contract and -registers its driver-specific implementation for internal managed-image -qualification and rebuild handoffs. Podman remains absent from the production +registers its driver-specific implementation for stock managed-image onboarding +and rebuild handoffs. Podman remains absent from the production provider registry. OpenClaw, Hermes, and LangChain Deep Agents Code images compile and package the freestanding amd64 or arm64 native entrypoint, its non-executable shell body, the root-owned hold helper, the composed `managed-bootstrap/image-runtime.ts` bundle, and the complete capability union. Pull-request and publication workflows build the exact images and exercise the protected envelope, native bootstrap, production held-command renderer, and -all-agent hold contracts. Ordinary onboarding continues to use Dockerfile builds, -and no public interface advertises buildless support. Supported activation, -canonical durable authority, and protected qualification remain tracked in +all-agent hold contracts. Ordinary OpenShell Docker-driver onboarding selects the exact +managed images for the shipped agents. Portable onboarding, non-managed agents, +and explicit `--from` custom Dockerfiles retain their previous workload paths; +native Podman remains disabled. Further provider expansion remains tracked in [epic #7744](https://github.com/NVIDIA/NemoClaw/issues/7744). diff --git a/src/lib/onboard/managed-bootstrap/docker-runtime.test.ts b/src/lib/onboard/managed-bootstrap/docker-runtime.test.ts index dea1966ca1e..164fc32358c 100644 --- a/src/lib/onboard/managed-bootstrap/docker-runtime.test.ts +++ b/src/lib/onboard/managed-bootstrap/docker-runtime.test.ts @@ -40,7 +40,10 @@ import type { ManagedBootstrapActivatedTransaction, ManagedBootstrapPreparedTransaction, } from "./adapter"; -import { createDockerManagedBootstrapSurface } from "./docker-runtime"; +import { + completeDockerManagedNativeGpuFallbackOwnerCleanup, + createDockerManagedBootstrapSurface, +} from "./docker-runtime"; import { authority, IDENTITY, NEW_ID, OLD_ID } from "./docker-test-fixture"; beforeEach(() => { @@ -63,6 +66,54 @@ afterEach(() => { vi.useRealTimers(); }); +describe("Docker managed-bootstrap native fallback owner cleanup", () => { + const handoff = Object.freeze({ + kind: "openshell-owner-cleanup-required" as const, + sandboxName: "alpha", + sandboxId: "sandbox-alpha", + runtimeId: NEW_ID, + }); + it("retains the exact handoff instead of deleting a mutable sandbox name", async () => { + const runOpenshell = vi.fn(() => { + throw new Error("name-only OpenShell cleanup must not run"); + }); + const recoverUnfinished = vi.fn(); + + await expect( + completeDockerManagedNativeGpuFallbackOwnerCleanup({ + providerId: "docker", + bootstrapIdentity: IDENTITY, + handoff, + runOpenshell, + recoverUnfinished, + }), + ).resolves.toBe(handoff); + expect(runOpenshell).not.toHaveBeenCalled(); + expect(recoverUnfinished).not.toHaveBeenCalled(); + }); + + it("blocks fallback even if a mutable name would resolve to the expected ID", async () => { + const runOpenshell = vi.fn(() => ({ + status: 0, + stdout: "ID: sandbox-alpha\n", + stderr: "", + })); + const recoverUnfinished = vi.fn(); + + await expect( + completeDockerManagedNativeGpuFallbackOwnerCleanup({ + providerId: "docker", + bootstrapIdentity: IDENTITY, + handoff, + runOpenshell, + recoverUnfinished, + }), + ).resolves.toBe(handoff); + expect(runOpenshell).not.toHaveBeenCalled(); + expect(recoverUnfinished).not.toHaveBeenCalled(); + }); +}); + describe("Docker managed-bootstrap lifecycle composition", () => { it("activates a Ready managed hold before post-activation startup output", async () => { vi.useFakeTimers(); diff --git a/src/lib/onboard/managed-bootstrap/docker-runtime.ts b/src/lib/onboard/managed-bootstrap/docker-runtime.ts index 6ed9679f806..2010934141a 100644 --- a/src/lib/onboard/managed-bootstrap/docker-runtime.ts +++ b/src/lib/onboard/managed-bootstrap/docker-runtime.ts @@ -26,6 +26,8 @@ import { import { createDockerManagedBootstrapAdapter } from "./docker"; import { createDockerManagedBootstrapAuthorityStore } from "./docker-authority-store"; import type { + ManagedBootstrapNativeGpuFallbackOwnerCleanupHandoff, + ManagedBootstrapNativeGpuFallbackOwnerCleanupOutcome, ManagedBootstrapRuntimeCompatibilityLaunchInput, ManagedBootstrapRuntimeCreateLaunchResult, ManagedBootstrapRuntimeCreateLifecycle, @@ -39,6 +41,27 @@ type SupportedBootstrapSurface = Extract< { readonly supported: true } >; +type CompleteOwnerCleanupInput = Readonly<{ + providerId: string; + bootstrapIdentity: string; + handoff: ManagedBootstrapNativeGpuFallbackOwnerCleanupHandoff; + runOpenshell: NonNullable< + ManagedBootstrapRuntimeCreateLifecycleInput["dependencies"]["runOpenshell"] + >; + recoverUnfinished: ManagedBootstrapRuntimeCreateLifecycle["recoverUnfinished"]; +}>; + +/** + * Retain the owner-cleanup handoff until OpenShell exposes deletion bound to a + * durable sandbox ID. A preceding ID lookup cannot authorize the current + * name-only delete because a same-name replacement can race between calls. + */ +export function completeDockerManagedNativeGpuFallbackOwnerCleanup( + input: CompleteOwnerCleanupInput, +): Promise { + return Promise.resolve(input.handoff); +} + function dockerReplacementOptions( mode: DockerGpuPatchMode, input: ManagedBootstrapRuntimeCreateLifecycleInput, @@ -159,6 +182,18 @@ function createDockerLifecycle( } : null; }, + async completeNativeGpuFallbackOwnerCleanup(handoff) { + if (handoff.sandboxName !== input.sandboxName || !input.dependencies.runOpenshell) { + return handoff; + } + return completeDockerManagedNativeGpuFallbackOwnerCleanup({ + providerId, + bootstrapIdentity: input.bootstrapIdentity, + handoff, + runOpenshell: input.dependencies.runOpenshell, + recoverUnfinished: () => recoverManagedBootstrapTransactions(adapter), + }); + }, async recoverUnfinished() { return recoverManagedBootstrapTransactions(adapter); }, diff --git a/src/lib/onboard/managed-bootstrap/runtime-create.ts b/src/lib/onboard/managed-bootstrap/runtime-create.ts index 0f7fe764d0f..872d3a66d0e 100644 --- a/src/lib/onboard/managed-bootstrap/runtime-create.ts +++ b/src/lib/onboard/managed-bootstrap/runtime-create.ts @@ -37,6 +37,35 @@ export interface ManagedBootstrapRuntimeLimit { readonly hard: number; } +export type ManagedBootstrapNativeGpuFallbackRollbackRequest = Readonly<{ + ownerCleanupHandoff: "native-gpu-fallback-after-absent-attachment"; +}>; + +export type ManagedBootstrapNativeGpuFallbackRollbackOutcome = + | Readonly<{ kind: "rolled-back" }> + | Readonly<{ + kind: "openshell-owner-cleanup-required"; + sandboxName: string; + sandboxId: string; + runtimeId: string; + }>; + +export type ManagedBootstrapNativeGpuFallbackOwnerCleanupHandoff = Extract< + ManagedBootstrapNativeGpuFallbackRollbackOutcome, + { readonly kind: "openshell-owner-cleanup-required" } +>; + +export type ManagedBootstrapNativeGpuFallbackOwnerCleanupReceipt = Readonly<{ + kind: "openshell-owner-cleanup-completed"; + sandboxName: string; + sandboxId: string; + runtimeId: string; +}>; + +export type ManagedBootstrapNativeGpuFallbackOwnerCleanupOutcome = + | ManagedBootstrapNativeGpuFallbackOwnerCleanupHandoff + | ManagedBootstrapNativeGpuFallbackOwnerCleanupReceipt; + /** Provider-neutral lifecycle surface consumed by sandbox-create coordinators. */ export interface ManagedBootstrapRuntimePatch { maybeApplyDuringCreate(): void | Promise; @@ -44,7 +73,12 @@ export interface ManagedBootstrapRuntimePatch { replacementRuntimeId?(): string | null; createFailureMessage(): string | null; exitOnPatchError(): void | Promise; - rollbackManagedStartupAfterCreateFailure(): void | Promise; + rollbackManagedStartupAfterCreateFailure( + request?: ManagedBootstrapNativeGpuFallbackRollbackRequest, + ): + | void + | ManagedBootstrapNativeGpuFallbackRollbackOutcome + | Promise; ensureApplied(): void | Promise; waitForSupervisorReconnectIfNeeded(): void | Promise; commitAfterReady(): void | Promise; @@ -137,6 +171,10 @@ export interface ManagedBootstrapRuntimeCreateLifecycle { * `undefined` means activation has not selected a runtime yet; `null` fails closed. */ inspectNativeRuntime?(): ManagedBootstrapRuntimeSnapshot | null | undefined; + /** Consume an exact provider-owned handoff before a single compatibility retry. */ + completeNativeGpuFallbackOwnerCleanup?( + handoff: ManagedBootstrapNativeGpuFallbackOwnerCleanupHandoff, + ): Promise; recoverUnfinished(): Promise; prepareNetwork(): Promise; runCreate( diff --git a/src/lib/onboard/managed-image-catalog.test.ts b/src/lib/onboard/managed-image-catalog.test.ts index 31d3a17a739..ca86f2241da 100644 --- a/src/lib/onboard/managed-image-catalog.test.ts +++ b/src/lib/onboard/managed-image-catalog.test.ts @@ -104,6 +104,7 @@ function registryFixture(agent: ShippedManagedImageAgent, options: RegistryFixtu ), "org.opencontainers.image.source": `https://github.com/${MANAGED_IMAGE_SOURCE_REPOSITORY}`, "org.opencontainers.image.revision": REVISION, + "org.opencontainers.image.version": RELEASE, "io.nvidia.nemoclaw.managed-image.cohort": COHORT, ...options.labels, }; @@ -471,6 +472,23 @@ describe("managed image GHCR catalog", () => { ).rejects.toThrow(/source revision does not match the expected revision/); }); + it("rejects a qualification revision from a different immutable release", async () => { + const fixture = catalogFixture({ + openclaw: { + rootReference: REVISION, + labels: { "org.opencontainers.image.version": "v0.0.96" }, + }, + }); + + await expect( + resolveManagedImageCatalogFromGhcr({ + release: RELEASE, + revision: REVISION, + fetchImpl: fixture.fetchImpl, + }), + ).rejects.toThrow(/image release does not match the expected release/); + }); + it("fails closed when a dependent cohort alias is torn or absent", async () => { const fixture = catalogFixture({ hermes: { missingRoot: true } }); diff --git a/src/lib/onboard/managed-image/catalog.ts b/src/lib/onboard/managed-image/catalog.ts index 80f7f952596..d5513a8b091 100644 --- a/src/lib/onboard/managed-image/catalog.ts +++ b/src/lib/onboard/managed-image/catalog.ts @@ -442,6 +442,7 @@ function validateImageLabels( agent: ShippedManagedImageAgent, imageConfig: OciImageConfig, platform: ManagedImagePlatform, + expectedRelease?: string, ): { readonly cohort: ManagedImagePublicationCohort; readonly revision: string; @@ -473,6 +474,12 @@ function validateImageLabels( if (typeof cohort !== "string" || !COHORT_PATTERN.test(cohort)) { return invalid(`'${agent}' image publication cohort is not a supported identity`); } + if ( + expectedRelease !== undefined && + labels["org.opencontainers.image.version"] !== expectedRelease + ) { + return invalid(`'${agent}' image release does not match the expected release`); + } return { cohort: cohort as ManagedImagePublicationCohort, revision, @@ -493,6 +500,7 @@ async function resolveManagedImageContractAtReferenceFromGhcr(options: { readonly release: string; readonly fetchImpl: Fetch; readonly expectedCohort?: ManagedImagePublicationCohort; + readonly expectedRelease?: string; readonly expectedRevision?: string; readonly platform: ManagedImagePlatform; }): Promise { @@ -513,7 +521,12 @@ async function resolveManagedImageContractAtReferenceFromGhcr(options: { configDigest(imageManifest), root.token, ); - const identity = validateImageLabels(agent, imageConfig, options.platform); + const identity = validateImageLabels( + agent, + imageConfig, + options.platform, + options.expectedRelease, + ); if (options.expectedCohort !== undefined && identity.cohort !== options.expectedCohort) { return invalid(`'${agent}' image publication cohort does not match the OpenClaw cohort`); } @@ -593,6 +606,7 @@ export async function resolveManagedImageCatalogFromGhcr(options: { platform, fetchImpl, ...(revision === undefined ? {} : { expectedRevision: revision }), + ...(revision === undefined ? {} : { expectedRelease: release }), }); const cohortReference = `cohort-${openclaw.source.cohort}`; const dependentResults = await Promise.allSettled( @@ -607,6 +621,7 @@ export async function resolveManagedImageCatalogFromGhcr(options: { platform, fetchImpl, expectedCohort: openclaw.source.cohort, + ...(revision === undefined ? {} : { expectedRelease: release }), expectedRevision: openclaw.source.revision, }), ] as const, diff --git a/src/lib/onboard/managed-startup-image-runtime-handoff.test.ts b/src/lib/onboard/managed-startup-image-runtime-handoff.test.ts index 23f18f3d1da..0e6bb1d6011 100644 --- a/src/lib/onboard/managed-startup-image-runtime-handoff.test.ts +++ b/src/lib/onboard/managed-startup-image-runtime-handoff.test.ts @@ -16,7 +16,6 @@ import { applyManagedStartupCommandEnvironmentPlan, buildManagedStartupImageActionPlan, MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION, - MANAGED_STARTUP_MERGED_CA_FILE, normalizeHermesManagedConfigDescriptor, readStableRegularFile, serializeManagedStartupCompletionMarker, @@ -85,6 +84,31 @@ describe("managed startup image runtime handoff and descriptor integrity", () => owned(realLstatSync(file, options))) as typeof fs.lstatSync); } + function mockRuntimeDescriptorOwnership( + runtimeEnvironmentFile: string, + uid: bigint, + gid: bigint, + ): void { + const realFstatSync = fs.fstatSync.bind(fs); + const runtimeInode = fs.lstatSync(runtimeEnvironmentFile, { bigint: true }).ino; + vi.spyOn(fs, "fstatSync").mockImplementation(((descriptor: number, options: { bigint: true }) => { + const stat = realFstatSync(descriptor, options); + const isRuntimeDescriptor = stat.ino === runtimeInode; + const ownership = new Map([ + ["uid", isRuntimeDescriptor ? uid : 0n], + ["gid", isRuntimeDescriptor ? gid : 0n], + ]); + return new Proxy(stat, { + get(inner, property) { + const value = ownership.has(property) + ? ownership.get(property) + : (Reflect.get(inner, property, inner) as unknown); + return typeof value === "function" ? value.bind(inner) : value; + }, + }); + }) as typeof fs.fstatSync); + } + function writeCompletionFixture( profile: ManagedStartupProfile, corporateCaMerged = false, @@ -190,11 +214,16 @@ describe("managed startup image runtime handoff and descriptor integrity", () => ).toThrow(/completion marker does not match the requested profile/u); }); - it("rejects runtime handoff drift after a matching completion", () => { + it("rejects a replaced runtime handoff after a matching completion", () => { const fixture = writeCompletionFixture(managedStartupE2eProfile("hermes")); mockDescriptorOwnership(0n, 0n); - fs.chmodSync(fixture.runtimeEnvironmentFile, 0o644); - fs.appendFileSync(fixture.runtimeEnvironmentFile, "export NEMOCLAW_MODEL='tampered/model'\n"); + const originalRuntimeEnvironment = fs.readFileSync(fixture.runtimeEnvironmentFile, "utf8"); + fs.renameSync(fixture.runtimeEnvironmentFile, `${fixture.runtimeEnvironmentFile}.original`); + fs.writeFileSync( + fixture.runtimeEnvironmentFile, + `${originalRuntimeEnvironment}export NEMOCLAW_MODEL='tampered/model'\n`, + { mode: 0o444 }, + ); fs.chmodSync(fixture.runtimeEnvironmentFile, 0o444); expect(() => @@ -207,6 +236,67 @@ describe("managed startup image runtime handoff and descriptor integrity", () => ).toThrow(/runtime environment digest mismatch/u); }); + it("fails closed when the runtime handoff is missing", () => { + const fixture = writeCompletionFixture(managedStartupE2eProfile("openclaw")); + mockDescriptorOwnership(0n, 0n); + fs.unlinkSync(fixture.runtimeEnvironmentFile); + + expect(() => + verifyManagedStartupImageCompletion( + fixture.agent, + fixture.fingerprint, + fixture.completionFile, + fixture.runtimeEnvironmentFile, + ), + ).toThrow(expect.objectContaining({ code: "ENOENT" })); + }); + + it("fails closed when the runtime handoff is symlinked", () => { + const fixture = writeCompletionFixture(managedStartupE2eProfile("openclaw")); + mockDescriptorOwnership(0n, 0n); + const replacement = `${fixture.runtimeEnvironmentFile}.replacement`; + fs.renameSync(fixture.runtimeEnvironmentFile, replacement); + fs.symlinkSync(replacement, fixture.runtimeEnvironmentFile); + + expect(() => + verifyManagedStartupImageCompletion( + fixture.agent, + fixture.fingerprint, + fixture.completionFile, + fixture.runtimeEnvironmentFile, + ), + ).toThrow(/refusing unsafe or unreadable file/u); + }); + + it("fails closed when the runtime handoff mode is not 0444", () => { + const fixture = writeCompletionFixture(managedStartupE2eProfile("hermes")); + mockDescriptorOwnership(0n, 0n); + fs.chmodSync(fixture.runtimeEnvironmentFile, 0o640); + + expect(() => + verifyManagedStartupImageCompletion( + fixture.agent, + fixture.fingerprint, + fixture.completionFile, + fixture.runtimeEnvironmentFile, + ), + ).toThrow(/runtime environment must be root:root mode 0444/u); + }); + + it("fails closed when the runtime handoff is not root owned", () => { + const fixture = writeCompletionFixture(managedStartupE2eProfile("langchain-deepagents-code")); + mockRuntimeDescriptorOwnership(fixture.runtimeEnvironmentFile, 501n, 20n); + + expect(() => + verifyManagedStartupImageCompletion( + fixture.agent, + fixture.fingerprint, + fixture.completionFile, + fixture.runtimeEnvironmentFile, + ), + ).toThrow(/runtime environment must be root:root mode 0444/u); + }); + it("accepts merged CA paths without putting the CA payload in the readable handoff", () => { const fixture = writeCompletionFixture( managedStartupE2eProfile("langchain-deepagents-code", false, true), @@ -247,15 +337,17 @@ describe("managed startup image runtime handoff and descriptor integrity", () => NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: "0.25", NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "3", }, - unsetEnvironment: ["NEMOCLAW_MINIMAL_BOOTSTRAP"], + unsetEnvironment: ["NEMOCLAW_MINIMAL_BOOTSTRAP", "REQUESTS_CA_BUNDLE"], }; const script = serializeManagedStartupRuntimeEnvironment( { NEMOCLAW_MODEL: "model-with-'quote", NEMOCLAW_OBSERVABILITY: "0", + SSL_CERT_FILE: "/pre-resume-ca.pem", }, true, { + CURL_CA_BUNDLE: "/pre-resume-ca.pem", NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", NEMOCLAW_MODEL: "model-with-'quote", }, @@ -268,7 +360,9 @@ describe("managed startup image runtime handoff and descriptor integrity", () => expect(script).toContain("export NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS='3'"); expect(script).toContain("export NEMOCLAW_MANAGED_STARTUP_APPLIED='1'"); expect(script).toContain("export NEMOCLAW_MODEL='model-with-'\"'\"'quote'"); - expect(script).toContain(`export SSL_CERT_FILE='${MANAGED_STARTUP_MERGED_CA_FILE}'`); + expect(script).not.toMatch( + /^(?:export|unset) (?:CURL_CA_BUNDLE|GIT_SSL_CAINFO|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE)(?:=|$)/mu, + ); expect(script).toContain("export _NEMOCLAW_CORPORATE_CA_MERGED='1'"); expect(script).not.toContain("NEMOCLAW_STARTUP_PROFILE_B64"); expect(script).not.toContain("NEMOCLAW_CORPORATE_CA_B64"); @@ -278,9 +372,11 @@ describe("managed startup image runtime handoff and descriptor integrity", () => { NEMOCLAW_MODEL: "model-with-'quote", NEMOCLAW_OBSERVABILITY: "0", + SSL_CERT_FILE: "/pre-resume-ca.pem", }, true, { + CURL_CA_BUNDLE: "/pre-resume-ca.pem", NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", NEMOCLAW_MODEL: "model-with-'quote", }, @@ -289,6 +385,24 @@ describe("managed startup image runtime handoff and descriptor integrity", () => ).toBe(script); }); + it("serializes OpenClaw reasoning into the managed runtime handoff", () => { + const profile = managedStartupE2eProfile("openclaw"); + const mapped = mapManagedStartupProfileToAgentEnvironment({ + ...profile, + tuning: { ...profile.tuning, reasoning: true }, + }); + const script = serializeManagedStartupRuntimeEnvironment( + mapped.runtimeEnvironment, + false, + mapped.configurationEnvironment, + mapped.applicationRuntime, + ); + + expect(script.match(/^export NEMOCLAW_REASONING=.*$/gmu)).toEqual([ + "export NEMOCLAW_REASONING='true'", + ]); + }); + it("validates runtime plans while removing launch-only exports and unsets from child commands", () => { const ambient = { NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "stale", diff --git a/src/lib/onboard/managed-startup-image-runtime.test.ts b/src/lib/onboard/managed-startup-image-runtime.test.ts index b8681c9dd93..1a938a3d195 100644 --- a/src/lib/onboard/managed-startup-image-runtime.test.ts +++ b/src/lib/onboard/managed-startup-image-runtime.test.ts @@ -5,6 +5,11 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const childProcessMock = vi.hoisted(() => ({ + spawnSync: vi.fn(), +})); +vi.mock("node:child_process", () => childProcessMock); + const coordinatorMock = vi.hoisted(() => ({ coordinateManagedStartupApplication: vi.fn(), })); @@ -341,6 +346,7 @@ describe("managed startup image runtime", () => { } beforeEach(() => { + childProcessMock.spawnSync.mockReset().mockReturnValue({ error: undefined, status: 0 }); coordinatorMock.coordinateManagedStartupApplication.mockReset(); }); afterEach(() => { diff --git a/src/lib/onboard/managed-startup-runtime-alias.test.ts b/src/lib/onboard/managed-startup-runtime-alias.test.ts index 92cbf1adf58..28e957850d4 100644 --- a/src/lib/onboard/managed-startup-runtime-alias.test.ts +++ b/src/lib/onboard/managed-startup-runtime-alias.test.ts @@ -3,11 +3,16 @@ import { describe, expect, it } from "vitest"; import { managedStartupE2eProfile } from "../../../scripts/checks/generate-managed-startup-profile-fixture.mts"; +import { createBuiltInRenderTemplateResolver } from "../messaging/channels/index.ts"; import { slackManifest } from "../messaging/channels/slack/manifest.ts"; +import { teamsManifest } from "../messaging/channels/teams/manifest.ts"; import { wechatManifest } from "../messaging/channels/wechat/manifest.ts"; import { buildWechatSeedOpenClawAccountOutputs } from "../messaging/channels/wechat/hooks/seed-openclaw-account.ts"; +import { planAgentRender } from "../messaging/compiler/engines/agent-render-engine.ts"; import { + encodeManagedStartupProfile, type ManagedStartupJsonObject, + type ManagedStartupJsonValue, type ManagedStartupProfile, validateManagedStartupProfile, } from "./managed-startup/profile.ts"; @@ -68,6 +73,93 @@ function profileWithBuildSteps( }; } +function profileWithAgentRender( + agentRender: readonly ManagedStartupJsonObject[], +): ManagedStartupProfile { + const profile = managedStartupE2eProfile("openclaw"); + return { + ...profile, + messaging: { + plan: { + schemaVersion: 1, + agent: "openclaw", + agentRender, + }, + }, + }; +} + +async function teamsOpenClawChannelRender(): Promise { + const renders = await planAgentRender( + teamsManifest, + { + sandboxName: "managed-startup-test", + agent: "openclaw", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["teams"], + credentialAvailability: { MSTEAMS_APP_PASSWORD: true }, + }, + [ + { + channelId: "teams", + inputId: "appId", + kind: "config", + required: true, + statePath: "teamsConfig.appId", + value: "test-app-id", + }, + { + channelId: "teams", + inputId: "tenantId", + kind: "config", + required: true, + statePath: "teamsConfig.tenantId", + value: "test-tenant-id", + }, + { + channelId: "teams", + inputId: "webhookPort", + kind: "config", + required: false, + statePath: "teamsConfig.webhookPort", + value: "3978", + }, + { + channelId: "teams", + inputId: "requireMention", + kind: "config", + required: false, + statePath: "teamsConfig.requireMention", + value: "1", + }, + ], + undefined, + createBuiltInRenderTemplateResolver(), + ); + const render = renders.find((entry) => entry.renderId === "teams-openclaw-channel"); + expect(render).toBeDefined(); + return render as unknown as ManagedStartupJsonObject; +} + +function withTeamsWebhook( + render: ManagedStartupJsonObject, + webhook: unknown, +): ManagedStartupJsonObject { + return { + ...render, + value: { + ...(render.value as ManagedStartupJsonObject), + webhook: webhook as ManagedStartupJsonValue, + }, + }; +} + +function expectProfileTransportAccepted(profile: ManagedStartupProfile): void { + const validated = validateManagedStartupProfile(profile); + expect(() => encodeManagedStartupProfile(validated)).not.toThrow(); +} + function withWechatAccountToken( step: ManagedStartupJsonObject, token: string, @@ -129,9 +221,7 @@ describe("managed startup runtime aliases", () => { describe("managed startup messaging build files", () => { it("accepts the stock WeChat account token placeholder (#9397)", () => { - expect(() => - validateManagedStartupProfile(profileWithBuildSteps([wechatAccountBuildStep()])), - ).not.toThrow(); + expectProfileTransportAccepted(profileWithBuildSteps([wechatAccountBuildStep()])); }); it.each([ @@ -221,4 +311,77 @@ describe("managed startup messaging build files", () => { ), ).toThrow(/credential-shaped/); }); + + it.each([ + ["a non-string savedAt", { savedAt: 1 }], + ["a non-string baseUrl", { baseUrl: 1 }], + ["a non-string userId", { userId: 1 }], + ["an extra content field", { note: "unexpected" }], + ])("rejects the WeChat token placeholder with %s (#9397)", (_label, change) => { + const step = wechatAccountBuildStep(); + const value = step.value as ManagedStartupJsonObject; + const content = value.content as ManagedStartupJsonObject; + expect(() => + validateManagedStartupProfile( + profileWithBuildSteps([ + { ...step, value: { ...value, content: { ...content, ...change } } }, + ]), + ), + ).toThrow(/credential-shaped/); + }); +}); + +describe("managed startup messaging agent renders", () => { + it("accepts the stock Microsoft Teams webhook object (#9610)", async () => { + expectProfileTransportAccepted(profileWithAgentRender([await teamsOpenClawChannelRender()])); + }); + + it.each([ + ["a string webhook", "openshell:resolve:env:MSTEAMS_APP_PASSWORD"], + ["a raw credential", `xoxb-${"a".repeat(32)}`], + ["a string port", { port: "3978", path: "/api/messages" }], + ["a zero port", { port: 0, path: "/api/messages" }], + ["an out-of-range port", { port: 65_536, path: "/api/messages" }], + ["a fractional port", { port: 3978.5, path: "/api/messages" }], + ["another path", { port: 3978, path: "/other" }], + [ + "an extra credential-shaped field", + { port: 3978, path: "/api/messages", token: `teams-${"a".repeat(32)}` }, + ], + ])("rejects %s in the Microsoft Teams render (#9610)", async (_label, webhook) => { + const render = withTeamsWebhook(await teamsOpenClawChannelRender(), webhook); + expect(() => validateManagedStartupProfile(profileWithAgentRender([render]))).toThrow( + /credential-shaped/, + ); + }); + + it.each([ + ["another channel", { channelId: "slack" }], + ["another render", { renderId: "teams-other-render" }], + ["another hook", { hookId: "teams-other-hook" }], + ["another handler", { handler: "teams.otherHandler" }], + ["another kind", { kind: "env-lines" }], + ["another agent", { agent: "hermes" }], + ["another target", { target: "other.json" }], + ["another config path", { path: "channels.other" }], + ])("rejects the Microsoft Teams webhook in %s (#9610)", async (_label, change) => { + const render = await teamsOpenClawChannelRender(); + expect(() => + validateManagedStartupProfile(profileWithAgentRender([{ ...render, ...change }])), + ).toThrow(/credential-shaped field name/); + }); + + it("rejects the Microsoft Teams webhook at an unowned path (#9610)", async () => { + const render = await teamsOpenClawChannelRender(); + const value = render.value as ManagedStartupJsonObject; + const webhook = value.webhook as ManagedStartupJsonObject; + const { webhook: _webhook, ...valueWithoutWebhook } = value; + expect(() => + validateManagedStartupProfile( + profileWithAgentRender([ + { ...render, value: { ...valueWithoutWebhook, metadata: { webhook } } }, + ]), + ), + ).toThrow(/credential-shaped field name/); + }); }); diff --git a/src/lib/onboard/managed-startup-system-ca.test.ts b/src/lib/onboard/managed-startup-system-ca.test.ts new file mode 100644 index 00000000000..09eaf1f5bbc --- /dev/null +++ b/src/lib/onboard/managed-startup-system-ca.test.ts @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const childProcessMock = vi.hoisted(() => ({ + spawnSync: vi.fn(), +})); +vi.mock("node:child_process", () => childProcessMock); + +import { mockRootReplayFilesystem } from "../../../test/helpers/managed-startup-root-replay-filesystem"; +import { PEM } from "./__test-helpers__/corporate-ca-fixtures"; +import { installCorporateCaSystemAnchors } from "./managed-startup/image-runtime"; + +const ANCHOR_DIRECTORY = "/usr/local/share/ca-certificates"; +const CORPORATE_CA_FILE = "/var/lib/nemoclaw/corporate-ca-source.pem"; +const SYSTEM_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"; +const UPDATE_CA_CERTIFICATES = "/usr/sbin/update-ca-certificates"; + +describe("managed startup system CA trust", () => { + beforeEach(() => { + childProcessMock.spawnSync.mockReset(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("installs each corporate certificate before refreshing system trust (#9360)", () => { + let filesystem: ReturnType; + childProcessMock.spawnSync.mockImplementation(() => { + filesystem.writeFile(SYSTEM_CA_BUNDLE, PEM, 0o444); + return { error: undefined, status: 0 }; + }); + filesystem = mockRootReplayFilesystem( + [], + new Map([ + [CORPORATE_CA_FILE, { contents: `${PEM}${PEM}`, mode: 0o444 }], + [SYSTEM_CA_BUNDLE, { contents: PEM, mode: 0o444 }], + [UPDATE_CA_CERTIFICATES, { contents: "executable", mode: 0o555 }], + ]), + ); + + installCorporateCaSystemAnchors(CORPORATE_CA_FILE); + + expect(filesystem.readFile(`${ANCHOR_DIRECTORY}/nemoclaw-corporate-ca-01.crt`)).toBe(PEM); + expect(filesystem.readFile(`${ANCHOR_DIRECTORY}/nemoclaw-corporate-ca-02.crt`)).toBe(PEM); + expect(childProcessMock.spawnSync).toHaveBeenCalledWith( + UPDATE_CA_CERTIFICATES, + [], + expect.objectContaining({ + env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }, + stdio: "inherit", + }), + ); + }); + + it("removes stale managed anchors and refreshes system trust when the profile has no CA (#9360)", () => { + const anchor = `${ANCHOR_DIRECTORY}/nemoclaw-corporate-ca-01.crt`; + const filesystem = mockRootReplayFilesystem( + [], + new Map([ + [anchor, { contents: PEM, mode: 0o444 }], + [SYSTEM_CA_BUNDLE, { contents: PEM, mode: 0o444 }], + [UPDATE_CA_CERTIFICATES, { contents: "executable", mode: 0o555 }], + ]), + ); + childProcessMock.spawnSync.mockReturnValue({ error: undefined, status: 0 }); + + installCorporateCaSystemAnchors(null); + + expect(filesystem.hasFile(anchor)).toBe(false); + expect(childProcessMock.spawnSync).toHaveBeenCalledOnce(); + }); + + it("refreshes system trust again after stale-anchor cleanup previously failed (#9360)", () => { + const anchor = `${ANCHOR_DIRECTORY}/nemoclaw-corporate-ca-01.crt`; + const filesystem = mockRootReplayFilesystem( + [], + new Map([ + [anchor, { contents: PEM, mode: 0o444 }], + [SYSTEM_CA_BUNDLE, { contents: PEM, mode: 0o444 }], + [UPDATE_CA_CERTIFICATES, { contents: "executable", mode: 0o555 }], + ]), + ); + childProcessMock.spawnSync + .mockReturnValueOnce({ error: undefined, status: 1 }) + .mockReturnValueOnce({ error: undefined, status: 0 }); + + expect(() => installCorporateCaSystemAnchors(null)).toThrow(/exited with status 1/u); + expect(filesystem.hasFile(anchor)).toBe(false); + + installCorporateCaSystemAnchors(null); + + expect(childProcessMock.spawnSync).toHaveBeenCalledTimes(2); + }); + + it("rejects an unsafe managed system CA anchor directory before cleanup (#9360)", () => { + const filesystem = mockRootReplayFilesystem( + [], + new Map([ + [SYSTEM_CA_BUNDLE, { contents: PEM, mode: 0o444 }], + [UPDATE_CA_CERTIFICATES, { contents: "executable", mode: 0o555 }], + ]), + ); + filesystem.chmodDirectory(ANCHOR_DIRECTORY, 0o777); + + expect(() => installCorporateCaSystemAnchors(null)).toThrow(/root:root directory/u); + expect(childProcessMock.spawnSync).not.toHaveBeenCalled(); + }); + + it("rejects a symlinked managed system CA anchor directory before cleanup (#9360)", () => { + const filesystem = mockRootReplayFilesystem( + [], + new Map([ + [SYSTEM_CA_BUNDLE, { contents: PEM, mode: 0o444 }], + [UPDATE_CA_CERTIFICATES, { contents: "executable", mode: 0o555 }], + ]), + ); + filesystem.markDirectorySymlink(ANCHOR_DIRECTORY); + + expect(() => installCorporateCaSystemAnchors(null)).toThrow(/root:root directory/u); + expect(childProcessMock.spawnSync).not.toHaveBeenCalled(); + }); + + it("fails when refreshed system trust omits the corporate CA (#9360)", () => { + const filesystem = mockRootReplayFilesystem( + [], + new Map([ + [CORPORATE_CA_FILE, { contents: PEM, mode: 0o444 }], + [SYSTEM_CA_BUNDLE, { contents: PEM, mode: 0o444 }], + [UPDATE_CA_CERTIFICATES, { contents: "executable", mode: 0o555 }], + ]), + ); + childProcessMock.spawnSync.mockImplementation(() => { + filesystem.writeFile(SYSTEM_CA_BUNDLE, "not a CA bundle\n", 0o444); + return { error: undefined, status: 0 }; + }); + + expect(() => installCorporateCaSystemAnchors(CORPORATE_CA_FILE)).toThrow( + /does not contain the corporate CA/u, + ); + }); +}); diff --git a/src/lib/onboard/managed-startup/image-runtime.ts b/src/lib/onboard/managed-startup/image-runtime.ts index 782eeae6ee0..cc499bdfa90 100644 --- a/src/lib/onboard/managed-startup/image-runtime.ts +++ b/src/lib/onboard/managed-startup/image-runtime.ts @@ -2,10 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; -import { createHash, randomBytes } from "node:crypto"; +import { createHash, randomBytes, X509Certificate } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { PEM_CERTIFICATE_RE_GLOBAL } from "../corporate-ca-policy"; import { type ManagedStartupAgentEnvironment, type ManagedStartupAgentMaterial, @@ -51,6 +52,17 @@ export const MANAGED_STARTUP_MERGED_CA_FILE = "/run/nemoclaw/managed-startup-ca- export const MANAGED_STARTUP_COMPLETION_FILE = "/run/nemoclaw/managed-startup-complete.json"; const MANAGED_STARTUP_CORPORATE_CA_FILE = "/usr/local/share/nemoclaw/corporate-ca.pem"; +const MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY = "/usr/local/share/ca-certificates"; +const MANAGED_STARTUP_SYSTEM_CA_ANCHOR_RE = /^nemoclaw-corporate-ca-[0-9]{2}\.crt$/u; +const SYSTEM_CA_BUNDLE_FILE = "/etc/ssl/certs/ca-certificates.crt"; +const UPDATE_CA_CERTIFICATES_EXECUTABLE = "/usr/sbin/update-ca-certificates"; +const MANAGED_STARTUP_TLS_ENV_NAMES = new Set([ + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "NODE_EXTRA_CA_CERTS", + "REQUESTS_CA_BUNDLE", + "SSL_CERT_FILE", +]); const MESSAGING_RUNTIME_PLAN_FILE = "/usr/local/share/nemoclaw/messaging-runtime-plan.json"; const ROOT_STATE_PARENT = "/var/lib/nemoclaw"; const ROOT_RUNTIME_DIRECTORY = "/run/nemoclaw"; @@ -1015,6 +1027,114 @@ function installCorporateCa(corporateCaPath: string | null): void { atomicWriteRootFile(MANAGED_STARTUP_CORPORATE_CA_FILE, bytes, 0o444); } +function corporateCaCertificateBlocks(corporateCaPath: string): readonly string[] { + const corporate = readStableRegularFile(corporateCaPath, 128 * 1024).toString("utf8"); + const blocks = corporate.match(PEM_CERTIFICATE_RE_GLOBAL); + if (!blocks || blocks.length === 0) { + fail("corporate CA material contains no certificate"); + } + for (const block of blocks) { + try { + if (!new X509Certificate(block).ca) { + fail("corporate CA material contains a certificate that is not a CA"); + } + } catch (error) { + if (error instanceof ManagedStartupImageRuntimeError) throw error; + fail("corporate CA material contains an invalid certificate"); + } + } + return blocks.map((block) => `${block.trim()}\n`); +} + +function managedSystemCaAnchorNames(): readonly string[] { + try { + fs.lstatSync(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + fail("could not inspect the managed system CA anchor directory"); + } + requireRootOwnedDirectory( + MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY, + ROOT_OWNED_DIRECTORY_MODE, + ); + try { + return (fs.readdirSync(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY) as string[]) + .filter((name) => MANAGED_STARTUP_SYSTEM_CA_ANCHOR_RE.test(name)) + .sort(); + } catch (error) { + fail("could not inspect the managed system CA anchors"); + } +} + +function refreshSystemCaBundle(): void { + if (!trustedExecutable(UPDATE_CA_CERTIFICATES_EXECUTABLE)) { + fail(`a trusted ${UPDATE_CA_CERTIFICATES_EXECUTABLE} executable is required`); + } + const result = spawnSync(UPDATE_CA_CERTIFICATES_EXECUTABLE, [], { + encoding: "utf8", + env: { PATH: FIXED_PATH }, + stdio: "inherit", + }); + if (result.error) { + fail(`could not execute ${UPDATE_CA_CERTIFICATES_EXECUTABLE}: ${result.error.message}`); + } + if (result.status !== 0) { + fail( + `${UPDATE_CA_CERTIFICATES_EXECUTABLE} exited with status ${String(result.status ?? "unknown")}`, + ); + } +} + +function requireSystemCaBundleContains(blocks: readonly string[]): void { + const systemBundle = safeTrustBundle(SYSTEM_CA_BUNDLE_FILE); + if (systemBundle === null) fail("the refreshed system CA bundle is missing"); + const systemBlocks = systemBundle.toString("utf8").match(PEM_CERTIFICATE_RE_GLOBAL) ?? []; + const systemFingerprints = new Set(); + for (const block of systemBlocks) { + try { + systemFingerprints.add(new X509Certificate(block).fingerprint256); + } catch { + fail("the refreshed system CA bundle contains an invalid certificate"); + } + } + for (const block of blocks) { + if (!systemFingerprints.has(new X509Certificate(block).fingerprint256)) { + fail("the refreshed system CA bundle does not contain the corporate CA"); + } + } +} + +export function installCorporateCaSystemAnchors(corporateCaPath: string | null): void { + const existingNames = managedSystemCaAnchorNames(); + if (corporateCaPath === null) { + for (const name of existingNames) { + removeSafeRootFile(path.join(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY, name)); + } + refreshSystemCaBundle(); + return; + } + + ensureRootOwnedDirectory(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY); + const blocks = corporateCaCertificateBlocks(corporateCaPath); + const expectedNames = blocks.map( + (_block, index) => `nemoclaw-corporate-ca-${String(index + 1).padStart(2, "0")}.crt`, + ); + for (const name of existingNames) { + if (!expectedNames.includes(name)) { + removeSafeRootFile(path.join(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY, name)); + } + } + for (const [index, name] of expectedNames.entries()) { + atomicWriteRootFile( + path.join(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY, name), + blocks[index] as string, + 0o444, + ); + } + refreshSystemCaBundle(); + requireSystemCaBundleContains(blocks); +} + function safeTrustBundle(target: string): Buffer | null { try { const { bytes, stat } = readStableRegularFileSnapshot(target, MAX_TRUST_BUNDLE_BYTES); @@ -1104,14 +1224,8 @@ function materializeManagedStartupRuntimeEnvironment( NEMOCLAW_MANAGED_STARTUP_APPLIED: "1", }; if (corporateCaMerged) { - for (const name of [ - "CURL_CA_BUNDLE", - "GIT_SSL_CAINFO", - "NODE_EXTRA_CA_CERTS", - "REQUESTS_CA_BUNDLE", - "SSL_CERT_FILE", - ]) { - output[name] = MANAGED_STARTUP_MERGED_CA_FILE; + for (const name of MANAGED_STARTUP_TLS_ENV_NAMES) { + delete output[name]; } output._NEMOCLAW_CORPORATE_CA_MERGED = "1"; } @@ -1121,8 +1235,14 @@ function materializeManagedStartupRuntimeEnvironment( } } const unsetNames = new Set([ - ...Object.keys(configurationEnvironment).filter((name) => !Object.hasOwn(output, name)), - ...validatedApplicationRuntime.unsetEnvironment, + ...Object.keys(configurationEnvironment).filter( + (name) => + !Object.hasOwn(output, name) && + (!corporateCaMerged || !MANAGED_STARTUP_TLS_ENV_NAMES.has(name)), + ), + ...validatedApplicationRuntime.unsetEnvironment.filter( + (name) => !corporateCaMerged || !MANAGED_STARTUP_TLS_ENV_NAMES.has(name), + ), ]); for (const name of validatedApplicationRuntime.unsetEnvironment) { if (Object.hasOwn(output, name)) { @@ -1331,6 +1451,7 @@ function applyAdapter( } installRootOwnedMaterials(mapped.materials); installCorporateCa(context.corporateCaPath); + installCorporateCaSystemAnchors(context.corporateCaPath); mergeCorporateCa(context.corporateCaPath); } @@ -1400,6 +1521,7 @@ export async function applyManagedStartupImageProfile( fail("committed corporate CA material drifted"); } } + installCorporateCaSystemAnchors(result.application.corporateCaPath); corporateCaMerged = mergeCorporateCa(result.application.corporateCaPath); } const runtimeEnvironment = serializeManagedStartupRuntimeEnvironment( diff --git a/src/lib/onboard/managed-startup/profile.ts b/src/lib/onboard/managed-startup/profile.ts index 79185126291..76e6c6442db 100644 --- a/src/lib/onboard/managed-startup/profile.ts +++ b/src/lib/onboard/managed-startup/profile.ts @@ -5,7 +5,7 @@ import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; import { TextDecoder } from "node:util"; import { listMessagingCredentialEnvAssignments } from "../../messaging/channels/metadata.ts"; -import { authorizeMessagingManagedStartupPlaceholders } from "../../messaging/managed-startup-placeholders.ts"; +import { authorizeMessagingManagedStartupFields } from "../../messaging/managed-startup-placeholders.ts"; import { isValidDcodeUpstreamProvider } from "./dcode-upstream-provider.ts"; /** @@ -1004,10 +1004,17 @@ function isMessagingCredentialPlaceholder( path: readonly string[], value: unknown, allowedBuildStepPlaceholders: ReadonlySet, + allowedMessagingCredentialFields: ReadonlySet, ): boolean { if (typeof value !== "string" || !MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value)) { return false; } + if ( + requiresMessagingSchemaFieldAuthorization(path) && + !allowedMessagingCredentialFields.has(messagingAuthorizedFieldKey(path)) + ) { + return false; + } const isCredentialBindingPlaceholder = path.length === 5 && path[0] === "messaging" && @@ -1032,6 +1039,15 @@ function isMessagingCredentialPlaceholder( ); } +function requiresMessagingSchemaFieldAuthorization(path: readonly string[]): boolean { + const fieldName = path[path.length - 1]; + return fieldName === "webhook"; +} + +function messagingAuthorizedFieldKey(path: readonly string[]): string { + return JSON.stringify(path); +} + function buildStepPlaceholderKey(path: readonly string[], value: string): string { return JSON.stringify([path, value]); } @@ -1540,6 +1556,7 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void { path: readonly string[]; }> = [{ value: root, depth: 0, path: [] }]; const allowedRuntimeAliasIndexes = new Set(); + const allowedMessagingCredentialFields = new Set(); const allowedBuildStepPlaceholders = new Set(); const selectedAgent = isPlainObject(root) ? ownDataPropertyValue(root, "agent") : undefined; let discoveredNodes = 1; @@ -1574,6 +1591,7 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void { current.path, current.value, allowedBuildStepPlaceholders, + allowedMessagingCredentialFields, ) && !isMessagingCredentialPlaceholderAssignment( selectedAgent, @@ -1640,17 +1658,25 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void { if (isCanonicalMessagingRuntimeEnvAlias(current.path, current.value)) { allowedRuntimeAliasIndexes.add(current.path[4] as string); } + const messagingPlanSection = current.path[2]; if ( current.path.length === 4 && current.path[0] === "messaging" && current.path[1] === "plan" && - current.path[2] === "buildSteps" && + (messagingPlanSection === "buildSteps" || messagingPlanSection === "agentRender") && JSON_ARRAY_INDEX_SEGMENT_RE.test(current.path[3] ?? "") ) { - for (const authorization of authorizeMessagingManagedStartupPlaceholders(current.value)) { - allowedBuildStepPlaceholders.add( - buildStepPlaceholderKey([...current.path, ...authorization.path], authorization.value), - ); + for (const authorization of authorizeMessagingManagedStartupFields( + current.value, + messagingPlanSection, + )) { + const authorizedPath = [...current.path, ...authorization.path]; + allowedMessagingCredentialFields.add(messagingAuthorizedFieldKey(authorizedPath)); + if (typeof authorization.value === "string") { + allowedBuildStepPlaceholders.add( + buildStepPlaceholderKey(authorizedPath, authorization.value), + ); + } } } const keys = Object.getOwnPropertyNames(current.value); @@ -1682,10 +1708,14 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void { const child = descriptor.value; if ( isCredentialShapedName(key) && + !allowedMessagingCredentialFields.has( + messagingAuthorizedFieldKey([...current.path, key]), + ) && !isMessagingCredentialPlaceholder( [...current.path, key], child, allowedBuildStepPlaceholders, + allowedMessagingCredentialFields, ) && !isMessagingPackagePin([...current.path, key], child) && !isStockTeamsOpenClawWebhook(root, [...current.path, key], child) diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.test.ts b/src/lib/onboard/managed-workload/onboard-orchestration.test.ts index 9452bcfbbc4..55eb0e8b094 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.test.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.test.ts @@ -23,6 +23,7 @@ import { createManagedWorkloadOnboardRuntime, prepareHermesPortableSandboxWorkloadForLifecycle, prepareOnboardSandboxWorkloadLaunch, + shouldActivateStockManagedRuntime, } from "./onboard-orchestration"; function createFreshOnboardingRuntime(environment: Readonly>) { @@ -94,6 +95,61 @@ async function expectUnsupportedHermesPortableSources( } describe("managed workload onboard orchestration", () => { + it("activates stock managed images only for shipped agents outside Portable", () => { + expect( + shouldActivateStockManagedRuntime({ + portableLifecycle: false, + hermesPortableLifecycle: false, + agentName: "openclaw", + }), + ).toBe(true); + expect( + shouldActivateStockManagedRuntime({ + portableLifecycle: false, + hermesPortableLifecycle: false, + agentName: "hermes", + }), + ).toBe(true); + expect( + shouldActivateStockManagedRuntime({ + portableLifecycle: false, + hermesPortableLifecycle: false, + agentName: "langchain-deepagents-code", + }), + ).toBe(true); + expect( + shouldActivateStockManagedRuntime({ + portableLifecycle: true, + hermesPortableLifecycle: false, + agentName: "openclaw", + }), + ).toBe(false); + expect( + shouldActivateStockManagedRuntime({ + portableLifecycle: false, + hermesPortableLifecycle: false, + agentName: "nemocua", + }), + ).toBe(false); + expect( + shouldActivateStockManagedRuntime({ + portableLifecycle: false, + hermesPortableLifecycle: false, + agentName: "pi", + }), + ).toBe(false); + }); + + it("does not activate stock managed images for Hermes Portable (#9634)", () => { + expect( + shouldActivateStockManagedRuntime({ + portableLifecycle: false, + hermesPortableLifecycle: true, + agentName: "hermes", + }), + ).toBe(false); + }); + it("selects only the shipped Hermes Dockerfile fallback without profile or prebuild work", async () => { const expectedDockerfilePath = "/workspace/agents/hermes/Dockerfile"; const ensurePreparedProfile = vi.fn(() => null); diff --git a/src/lib/onboard/managed-workload/onboard-orchestration.ts b/src/lib/onboard/managed-workload/onboard-orchestration.ts index f137efcd80b..bdf12aff70b 100644 --- a/src/lib/onboard/managed-workload/onboard-orchestration.ts +++ b/src/lib/onboard/managed-workload/onboard-orchestration.ts @@ -20,7 +20,10 @@ import { } from "../docker-gpu-route"; import type { HermesDashboardOnboardState } from "../hermes-dashboard"; import type { InitialSandboxPolicy } from "../initial-policy"; -import { managedImageRuntimeIdentity } from "../managed-image/contract"; +import { + isShippedManagedImageAgent, + managedImageRuntimeIdentity, +} from "../managed-image/contract"; import { type BuiltManagedStartupOnboardProfile, buildManagedStartupOnboardProfile, @@ -143,6 +146,18 @@ export interface ManagedWorkloadOnboardRuntime { ): BuiltManagedStartupOnboardProfile | null; } +export function shouldActivateStockManagedRuntime(input: { + readonly portableLifecycle: boolean; + readonly hermesPortableLifecycle: boolean; + readonly agentName: string; +}): boolean { + return ( + !input.portableLifecycle && + !input.hermesPortableLifecycle && + isShippedManagedImageAgent(input.agentName) + ); +} + export function assertPortableManagedBootstrapNotSelected( portableLifecycle: boolean, managedBootstrapSelected: boolean, diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts index 06e84b882bf..015fda9710d 100644 --- a/src/lib/onboard/messaging-prep.test.ts +++ b/src/lib/onboard/messaging-prep.test.ts @@ -43,9 +43,14 @@ function createInput( } describe("prepareCreateSandboxMessaging", () => { - it("filters token definitions by selected and disabled channels and reuses attached missing-token providers", () => { + it("filters token definitions and reuses missing-token providers with matching bindings", () => { const registerExtraPlaceholderProviders = vi.fn(() => ["SLACK_BOT_TOKEN_AGENT_A"]); - const providerExistsInGateway = vi.fn((name: string) => name === "demo-slack-bridge"); + const providerMatchesGatewayCredential = vi.fn( + (name: string, type: string, credentialKey: string) => + name === "demo-slack-bridge" && + type === "nemoclaw-mcp-v1" && + credentialKey === "SLACK_BOT_TOKEN", + ); const result = prepareCreateSandboxMessaging( createInput({ @@ -54,7 +59,7 @@ describe("prepareCreateSandboxMessaging", () => { getValidatedMessagingTokenByEnvKey: (_channels, envKey) => envKey === "SLACK_APP_TOKEN" ? "xapp-valid" : null, registerExtraPlaceholderProviders, - providerExistsInGateway, + providerMatchesGatewayCredential, }), ); @@ -67,7 +72,11 @@ describe("prepareCreateSandboxMessaging", () => { expect(result.hasMessagingTokens).toBe(true); expect(result.reusableMessagingProviders).toEqual(["demo-slack-bridge"]); expect(result.reusableMessagingChannels).toEqual(["slack"]); - expect(providerExistsInGateway).toHaveBeenCalledWith("demo-slack-bridge"); + expect(providerMatchesGatewayCredential).toHaveBeenCalledWith( + "demo-slack-bridge", + "nemoclaw-mcp-v1", + "SLACK_BOT_TOKEN", + ); expect(registerExtraPlaceholderProviders).toHaveBeenCalledWith( "demo", result.messagingTokenDefs, @@ -392,6 +401,33 @@ describe("prepareCreateSandboxMessaging", () => { expect(providerMatchesGatewayCredential).not.toHaveBeenCalled(); }); + it("binds static messaging credentials to the endpointless provider profile (#9875)", () => { + const result = prepareCreateSandboxMessaging( + createInput({ + enabledChannels: ["discord", "slack"], + getValidatedMessagingTokenByEnvKey: (_channels, envKey) => `${envKey}-value`, + }), + ); + + expect(result.messagingTokenDefs).toMatchObject([ + { + name: "demo-discord-bridge", + envKey: "DISCORD_BOT_TOKEN", + providerType: "nemoclaw-mcp-v1", + }, + { + name: "demo-slack-bridge", + envKey: "SLACK_BOT_TOKEN", + providerType: "nemoclaw-mcp-v1", + }, + { + name: "demo-slack-app", + envKey: "SLACK_APP_TOKEN", + providerType: "nemoclaw-mcp-v1", + }, + ]); + }); + it("uses BRAVE_API_KEY from host env when the credential store has no value", () => { const result = prepareCreateSandboxMessaging( createInput({ diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts index a8e8657f21e..ce6284cb32c 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -4,6 +4,7 @@ import type { WebSearchConfig } from "../inference/web-search"; import * as webSearch from "../inference/web-search"; import { listMessagingCredentialMetadata } from "../messaging/channels"; +import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../messaging/provider-profile"; import { type ChannelDef, getChannelTokenKeys } from "../sandbox/channels"; import * as braveProviderProfile from "./brave-provider-profile"; import { @@ -82,19 +83,17 @@ export function prepareCreateSandboxMessaging( const messagingProviderProfiles = messagingBridgeProfilesForAgent(input.agentName); const messagingTokenDefs: MessagingTokenDef[] = listMessagingCredentialMetadata() - .map((credential) => { - const providerType = staticMessagingProviderTypeForChannel( - credential.channelId, - input.agentName, - messagingProviderProfiles, - ); - return { - name: credential.providerNameTemplate.replaceAll("{sandboxName}", input.sandboxName), - envKey: credential.providerEnvKey, - token: input.getValidatedMessagingTokenByEnvKey(input.channels, credential.providerEnvKey), - ...(providerType ? { providerType } : {}), - }; - }) + .map((credential) => ({ + name: credential.providerNameTemplate.replaceAll("{sandboxName}", input.sandboxName), + envKey: credential.providerEnvKey, + token: input.getValidatedMessagingTokenByEnvKey(input.channels, credential.providerEnvKey), + providerType: + staticMessagingProviderTypeForChannel( + credential.channelId, + input.agentName, + messagingProviderProfiles, + ) ?? MESSAGING_CREDENTIAL_PROVIDER_TYPE, + })) .filter(({ envKey }) => !enabledEnvKeys || enabledEnvKeys.has(envKey)) .filter(({ envKey }) => !disabledEnvKeys.has(envKey)); diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index d14779d0cef..ed31518d272 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -250,11 +250,25 @@ export function computeSetupPresetSuggestions( env = process.env, } = options; const known = Array.isArray(options.knownPresetNames) ? new Set(options.knownPresetNames) : null; + const activeMessagingPresets = Array.isArray(enabledChannels) + ? new Set(allMessagingChannelPolicyPresets(enabledChannels)) + : null; + const hermesAgent = typeof agent === "string" && agent.trim().toLowerCase() === "hermes"; const supportOptions = { webSearchSupported: options.webSearchSupported }; const suggestions = deps.tiers .resolveTierPresets(tierName) .map((preset) => preset.name) .filter((name) => setupPolicyPresetAppliesToAgent(name, agent)) + // Hermes Discord egress names a sandbox-scoped credential provider. An + // open tier may contain the preset, but OpenShell rejects it unless the + // channel is active and its provider is attached to the sandbox. + .filter( + (name) => + !hermesAgent || + name !== "discord" || + activeMessagingPresets === null || + activeMessagingPresets.has(name), + ) .filter( (name) => !isStaleBuiltinWebSearchPolicyPreset(name, { diff --git a/src/lib/onboard/preflight-messages.test.ts b/src/lib/onboard/preflight-messages.test.ts index 04ef9c5779b..ed17edc450c 100644 --- a/src/lib/onboard/preflight-messages.test.ts +++ b/src/lib/onboard/preflight-messages.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { GpuDetection } from "../inference/nim"; +import { setOnboardBrandingAgent } from "./branding"; import { printCdiSpecUnavailableError, printDockerNotReachableError, @@ -37,6 +38,8 @@ function withStderrColorDepth(colorDepth: number, callback: () => T): T { describe("onboard preflight severity messages (#6004)", () => { afterEach(() => { + setOnboardBrandingAgent(null); + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -121,11 +124,15 @@ describe("onboard preflight severity messages (#6004)", () => { expect(lines(warn).join("\n")).toContain("may fail with OOM"); }); - it("prints a missing messaging provider to stderr with a ⚠ marker and fix hint", () => { + it("routes missing messaging provider repair through profile-aware onboarding (#9875)", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + setOnboardBrandingAgent("hermes"); + vi.stubEnv("NEMOCLAW_INVOKED_AS", "nemohermes"); printMessagingProviderMissing("slack"); expect(lines(warn)[0]).toContain("⚠ Messaging provider 'slack' was not found in the gateway."); - expect(lines(warn).join("\n")).toContain("openshell provider create --name slack"); + expect(lines(warn).join("\n")).toContain( + "rerun nemohermes onboard with the required messaging credentials", + ); }); }); diff --git a/src/lib/onboard/preflight-messages.ts b/src/lib/onboard/preflight-messages.ts index 0b9772fd20e..943b486e251 100644 --- a/src/lib/onboard/preflight-messages.ts +++ b/src/lib/onboard/preflight-messages.ts @@ -14,7 +14,7 @@ import { failLine, warnLine } from "../cli/terminal-style"; import { formatNvidiaGpuPreflightLines, type GpuDetection } from "../inference/nim"; -import { cliDisplayName } from "./branding"; +import { cliDisplayName, cliName } from "./branding"; import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; /** Docker cannot be reached, so onboarding cannot continue. */ @@ -94,7 +94,7 @@ export function printMessagingProviderMissing(providerName: string): void { console.warn(warnLine(`Messaging provider '${providerName}' was not found in the gateway.`)); console.warn(" The credential may not be available inside the sandbox."); console.warn( - ` To fix: openshell provider create --name ${providerName} --type generic --credential `, + ` To fix: rerun ${cliName()} onboard with the required messaging credentials so NemoClaw can register the OpenShell provider profile.`, ); } diff --git a/src/lib/onboard/providers.test.ts b/src/lib/onboard/providers.test.ts index 54ab820454e..058d3943e3a 100644 --- a/src/lib/onboard/providers.test.ts +++ b/src/lib/onboard/providers.test.ts @@ -3,8 +3,22 @@ import { describe, expect, it } from "vitest"; -type RunResult = { status: number; stdout?: string; stderr?: string }; -type RunOptions = { env?: Record }; +type RunResult = { + error?: unknown; + output?: string; + signal?: unknown; + status: number; + stdout?: string; + stderr?: string; +}; +type RunOptions = { + env?: Record; + ignoreError?: boolean; + maxBuffer?: number; + stdio?: readonly unknown[]; + suppressOutput?: boolean; + timeout?: number; +}; type RunOpenshell = (command: string[], opts?: RunOptions) => RunResult; const DISCORD_STATIC_PROFILE_EXPORT = JSON.stringify({ @@ -84,7 +98,12 @@ const { baseUrl: string | null, env: Record, runOpenshell: RunOpenshell, - options?: { replaceExisting?: boolean; requireExactBinding?: boolean }, + options?: { + knownExists?: boolean; + replaceExisting?: boolean; + allowedSandboxes?: readonly string[]; + requireExactBinding?: boolean; + }, ) => { ok: boolean; status?: number; message?: string; reason?: string }; upsertMessagingProviders: ( tokenDefs: Array<{ @@ -95,8 +114,9 @@ const { }>, runOpenshell: RunOpenshell, options?: { - replaceExisting?: boolean; + allowedSandboxes?: readonly string[]; bestEffort?: boolean; + replaceExisting?: boolean; requireExactBindings?: boolean; }, ) => string[]; @@ -657,6 +677,201 @@ describe("onboard provider helpers", () => { ); }); + it("imports the endpointless profile before creating a static messaging provider (#9875)", () => { + const credential = "discord-credential-must-not-leak"; + const calls: Array<{ command: string[]; env?: Record }> = []; + let created = false; + const providers = upsertMessagingProviders( + [ + { + name: "alpha-discord-bridge", + envKey: "DISCORD_BOT_TOKEN", + token: credential, + providerType: "nemoclaw-mcp-v1", + }, + ], + (command, options) => { + calls.push({ command, env: options?.env }); + switch (command[1]) { + case "get": + return created + ? { + status: 0, + stdout: + "Name: alpha-discord-bridge\nType: nemoclaw-mcp-v1\nCredential keys: DISCORD_BOT_TOKEN\nConfig keys: \n", + } + : { + status: 1, + stdout: "", + stderr: "provider 'alpha-discord-bridge' not found", + }; + case "create": + created = true; + } + return { status: 0, stdout: "", stderr: "" }; + }, + ); + + expect(providers).toEqual(["alpha-discord-bridge"]); + expect(calls.map(({ command }) => command.join(" "))).toEqual([ + expect.stringMatching(/^provider profile import --file .*nemoclaw-mcp-v1\.yaml$/), + "provider get alpha-discord-bridge", + "provider create --name alpha-discord-bridge --type nemoclaw-mcp-v1 --credential DISCORD_BOT_TOKEN", + "provider get alpha-discord-bridge", + ]); + expect(calls[2]?.env).toEqual({ DISCORD_BOT_TOKEN: credential }); + expect(calls.flatMap(({ command }) => command)).not.toContain(credential); + }); + + it("rejects credential-free reuse when the messaging profile is incompatible (#9875)", () => { + const commands: string[] = []; + const profileResults: Record = { + import: { status: 1, stdout: "", stderr: "profile already exists" }, + export: { + status: 0, + stdout: JSON.stringify({ + id: "nemoclaw-mcp-v1", + credentials: [], + endpoints: [{ url: "https://foreign.example" }], + binaries: [], + inference_capable: false, + }), + stderr: "", + }, + }; + + expect(() => + upsertMessagingProviders( + [ + { + name: "alpha-discord-bridge", + envKey: "DISCORD_BOT_TOKEN", + token: null, + providerType: "nemoclaw-mcp-v1", + }, + ], + (command) => { + const joined = command.join(" "); + commands.push(joined); + return profileResults[command[2] ?? ""] ?? { status: 0, stdout: "", stderr: "" }; + }, + { bestEffort: true }, + ), + ).toThrow(/does not match NemoClaw's endpointless messaging credential contract/u); + expect(commands).toEqual([ + expect.stringMatching(/^provider profile import --file /u), + "provider profile export nemoclaw-mcp-v1 --output json", + ]); + }); + + it.each([ + ["alpha-discord-bridge", "DISCORD_BOT_TOKEN"], + ["alpha-slack-bridge", "SLACK_BOT_TOKEN"], + ])("rejects a live legacy provider before updating %s (#9875)", (name, credentialKey) => { + const commands: string[] = []; + + expect(() => + upsertMessagingProviders( + [ + { + name, + envKey: credentialKey, + token: "test-only-messaging-credential", + providerType: "nemoclaw-mcp-v1", + }, + ], + (command) => { + commands.push(command.join(" ")); + return command[1] === "profile" + ? { status: 0 } + : { + status: 0, + stdout: `Name: ${name}\nType: generic\nCredential keys: ${credentialKey}\nConfig keys: \n`, + }; + }, + { bestEffort: true }, + ), + ).toThrow(/does not match the required endpointless credential binding/); + expect(commands.some((command) => /provider (create|update)/u.test(command))).toBe(false); + }); + + it("rejects an ambiguous messaging provider lookup before mutation (#9875)", () => { + const mutations: string[] = []; + const getResult = { + status: 1, + stdout: "", + stderr: 'Error: status: Unavailable, message: "provider not found"', + }; + const profileResult = { status: 0, stdout: "", stderr: "" }; + + expect(() => + upsertMessagingProviders( + [ + { + name: "alpha-discord-bridge", + envKey: "DISCORD_BOT_TOKEN", + token: "credential", + providerType: "nemoclaw-mcp-v1", + }, + ], + (command) => { + const joined = command.join(" "); + const result = joined.startsWith("provider profile import ") + ? profileResult + : new Map([["provider get alpha-discord-bridge", getResult]]).get(joined); + mutations.push(...(result ? [] : [joined])); + return result ?? profileResult; + }, + { bestEffort: true }, + ), + ).toThrow(/Could not inspect messaging provider/); + expect(mutations).toEqual([]); + }); + + it.each([ + ["alpha-discord-bridge", "DISCORD_BOT_TOKEN"], + ["alpha-slack-bridge", "SLACK_BOT_TOKEN"], + ])("replaces a detached legacy provider before registering %s (#9875)", (name, credentialKey) => { + const commands: string[] = []; + let providerType = "generic"; + const providers = upsertMessagingProviders( + [ + { + name, + envKey: credentialKey, + token: "test-only-messaging-credential", + providerType: "nemoclaw-mcp-v1", + }, + ], + (command) => { + commands.push(command.join(" ")); + switch (command[1]) { + case "profile": + case "delete": + return { status: 0 }; + case "create": + providerType = "nemoclaw-mcp-v1"; + return { status: 0 }; + default: + return { + status: 0, + stdout: `Name: ${name}\nType: ${providerType}\nCredential keys: ${credentialKey}\nConfig keys: \n`, + }; + } + }, + { replaceExisting: true }, + ); + + expect(providers).toEqual([name]); + expect(commands).toEqual([ + expect.stringMatching(/^provider profile import --file /u), + `provider get ${name}`, + `provider delete ${name}`, + `provider create --name ${name} --type nemoclaw-mcp-v1 --credential ${credentialKey}`, + `provider get ${name}`, + ]); + }); + it("updates an existing Brave Search provider in place on reuse paths", () => { const commands: string[] = []; const providers = upsertMessagingProviders( @@ -944,6 +1159,44 @@ describe("onboard provider helpers", () => { ]); }); + it("does not detach a sibling sandbox while replacing a recreate-owned provider (#9875)", () => { + const commands: string[] = []; + + expect(() => + upsertMessagingProviders( + [ + { + name: "spark-nemo-telegram-bridge", + envKey: "TELEGRAM_BOT_TOKEN", + token: "tg-test", + providerType: "generic", + }, + ], + (command) => { + const joined = command.join(" "); + commands.push(joined); + return joined === "provider delete spark-nemo-telegram-bridge" + ? { + status: 1, + stdout: "", + stderr: + "Error: status: FailedPrecondition, message: \"provider 'spark-nemo-telegram-bridge' is attached to sandbox(es): sibling-live\"", + } + : { status: 0, stdout: "", stderr: "" }; + }, + { + replaceExisting: true, + bestEffort: true, + allowedSandboxes: ["spark-nemo"], + }, + ), + ).toThrow(/sibling-live/u); + expect(commands).toEqual([ + "provider get spark-nemo-telegram-bridge", + "provider delete spark-nemo-telegram-bridge", + ]); + }); + it("surfaces detach failures in the final error when delete retry still fails", () => { let originalExit: typeof process.exit = process.exit; let captured = ""; diff --git a/src/lib/onboard/providers.ts b/src/lib/onboard/providers.ts index e45451af741..324a45a62db 100644 --- a/src/lib/onboard/providers.ts +++ b/src/lib/onboard/providers.ts @@ -22,9 +22,14 @@ const { LLAMA_CPP_PROVIDER_NAME, } = require("../inference/llama-cpp/contract"); const { + inspectGatewayCredentialOnlyProviderBinding, matchesGatewayCredentialOnlyProviderBinding, readGatewayProviderMetadata, } = require("./gateway-provider-metadata"); +const { + ensureMessagingCredentialProviderProfile, + MESSAGING_CREDENTIAL_PROVIDER_TYPE, +} = require("../messaging/provider-profile"); const MESSAGING_PROVIDER_BINDING_CONFLICT = "NEMOCLAW_MESSAGING_PROVIDER_BINDING_CONFLICT"; @@ -405,7 +410,7 @@ function getRequestedModelHint(nonInteractive, allowHostedInferenceStaging = tru * Build the argument array for an `openshell provider create` or `update` command. * @param {"create"|"update"} action - Whether to create or update. * @param {string} name - Provider name. - * @param {string} type - Provider type (e.g. "openai", "anthropic", "generic"). + * @param {string} type - Provider type (for example, "openai" or "nemoclaw-mcp-v1"). * @param {string} credentialEnv - Credential environment variable name. * @param {string|null} baseUrl - Optional base URL for API-compatible endpoints. * @param {{ includeCredential?: boolean }} [opts] - When `includeCredential` is @@ -457,22 +462,22 @@ function providerExistsInGateway(name, _runOpenshell) { * Checks whether the provider already exists via `openshell provider get`; * uses `create` for new providers and `update` for existing ones. When * `options.replaceExisting` is true an existing provider is deleted and - * recreated instead of updated — required for provider-type changes that + * recreated instead of updated. This is required for provider-type changes that * `provider update` cannot apply (e.g. the Brave Search migration from the * legacy `generic` type to the `brave` profile). The caller must guarantee * the provider is detached from any live sandbox before opting in: OpenShell * rejects `provider delete` on attached providers. * @param {string} name - Provider name (e.g. "discord-bridge", "inference"). - * @param {string} type - Provider type ("openai", "anthropic", "generic", "brave"). + * @param {string} type - Provider type (for example, "openai", "brave", or "nemoclaw-mcp-v1"). * @param {string} credentialEnv - Environment variable name for the credential. * @param {string|null} baseUrl - Optional base URL for the provider endpoint. * @param {Record} env - Environment variables for the openshell command. * @param {Function} _runOpenshell - Injected runOpenshell from onboard.ts. - * @param {{replaceExisting?: boolean}} options - Optional replacement controls. + * @param {{replaceExisting?: boolean, knownExists?: boolean, allowedSandboxes?: readonly string[], requireExactBinding?: boolean}} options - Optional replacement controls. * @returns {{ ok: boolean, status?: number, message?: string, reason?: string }} */ function upsertProvider(name, type, credentialEnv, baseUrl, env, _runOpenshell, options = {}) { - const exists = providerExistsInGateway(name, _runOpenshell); + const exists = options.knownExists ?? providerExistsInGateway(name, _runOpenshell); if ( exists && options.requireExactBinding && @@ -492,7 +497,10 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env, _runOpenshell, } if (exists && options.replaceExisting) { const { deleteProviderWithRecovery } = require("./sandbox-provider-cleanup"); - const r = deleteProviderWithRecovery(name, { runOpenshell: _runOpenshell }); + const r = deleteProviderWithRecovery(name, { + runOpenshell: _runOpenshell, + allowedSandboxes: options.allowedSandboxes, + }); if (!r.ok) { const base = compactText(redact(r.stderr)) || @@ -557,7 +565,7 @@ function preflightMessagingProviderBindings(tokenDefs, _runOpenshell) { * of terminating the CLI. * @param {Array<{name: string, envKey: string, token: string|null, providerType?: string}>} tokenDefs * @param {Function} _runOpenshell - Injected runOpenshell from onboard.ts. - * @param {{replaceExisting?: boolean, bestEffort?: boolean, requireExactBindings?: boolean}} options - Forwarded to every upsertProvider call. + * @param {{replaceExisting?: boolean, bestEffort?: boolean, allowedSandboxes?: readonly string[], requireExactBindings?: boolean}} options - Forwarded to every upsertProvider call. * @returns {string[]} Provider names that were upserted. */ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { @@ -570,7 +578,7 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { // +----v-------------------------------------------------+ // | for (tokenDef of tokenDefs) <- THE LOOP | // | upsertProvider(name, providerType || "generic") | bridge created - // | . slack -> --type generic | with a sentinel + // | . slack -> --type nemoclaw-mcp-v1 | with a sentinel // | . googlechat -> --type google-chat-bridge | token // +----+-------------------------------------------------+ // | @@ -595,6 +603,23 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { } const messagingBridgeProvider = require("./messaging-bridge-provider"); + if ( + tokenDefs.some( + ({ providerType }) => providerType === MESSAGING_CREDENTIAL_PROVIDER_TYPE, + ) + ) { + try { + ensureMessagingCredentialProviderProfile({ + root: ROOT, + runOpenshell: _runOpenshell, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (options.bestEffort) throw new Error(message); + console.error(`\n ✗ ${message}`); + process.exit(1); + } + } messagingBridgeProvider.ensureMessagingBridgeProfiles(tokenDefs, { root: ROOT, runOpenshell: _runOpenshell, @@ -604,7 +629,30 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { const failures = []; for (const { name, envKey, token, providerType } of tokenDefs) { if (!token) continue; - const result = upsertProvider( + let knownExists; + let result; + if (providerType === MESSAGING_CREDENTIAL_PROVIDER_TYPE) { + const inspection = inspectGatewayCredentialOnlyProviderBinding( + { name, type: providerType, credentialKey: envKey }, + _runOpenshell, + ); + if (inspection.kind === "indeterminate") { + result = { + ok: false, + status: 1, + message: `Could not inspect messaging provider '${name}'; no provider mutation was attempted.`, + }; + } else if (inspection.kind === "collision" && !options.replaceExisting) { + result = { + ok: false, + status: 1, + message: `Messaging provider '${name}' does not match the required endpointless credential binding.`, + }; + } else { + knownExists = inspection.kind !== "missing"; + } + } + result ??= upsertProvider( name, providerType || "generic", envKey, @@ -613,9 +661,24 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) { _runOpenshell, { replaceExisting: Boolean(options.replaceExisting), + knownExists, + allowedSandboxes: options.allowedSandboxes, requireExactBinding: Boolean(options.requireExactBindings && providerType), }, ); + if (result.ok && providerType === MESSAGING_CREDENTIAL_PROVIDER_TYPE) { + const verified = inspectGatewayCredentialOnlyProviderBinding( + { name, type: providerType, credentialKey: envKey }, + _runOpenshell, + ); + if (verified.kind !== "exact") { + result = { + ok: false, + status: 1, + message: `OpenShell did not confirm messaging provider '${name}' after mutation.`, + }; + } + } if (!result.ok) { if (options.bestEffort) { failures.push({ name, message: result.message, reason: result.reason }); diff --git a/src/lib/onboard/runtime-provider/docker-state-mutation.test.ts b/src/lib/onboard/runtime-provider/docker-state-mutation.test.ts index c4a1c8d55ef..03a04896532 100644 --- a/src/lib/onboard/runtime-provider/docker-state-mutation.test.ts +++ b/src/lib/onboard/runtime-provider/docker-state-mutation.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; @@ -22,6 +23,7 @@ import { } from "../../../../test/helpers/docker-state-mutation-harness"; import { createDockerOperationAuthority } from "./docker-operation-authority"; import { + DOCKER_STATE_MUTATION_HELPER_TRANSPORT_BROKER_SOURCE, createDockerStateMutationOwner, createDockerStateMutationSurface, } from "./docker-state-mutation"; @@ -37,6 +39,7 @@ function ownerThatStopsAfterPrepare(runtime: ReturnType) { lifecycleGeneration: runtime.lifecycleGeneration, lifecycleLiveIdentityFingerprint: SANDBOX_FINGERPRINT, runtimeId: RUNTIME_ID, + hostTransportRoot: runtime.root, authority: runtime.authority as ReturnType, engineAuthorityStore: runtime.engineAuthorityStore, lifecycleStore: { @@ -52,6 +55,50 @@ afterEach(() => { }); describe("Docker runtime-provider state mutation surface", () => { + it("preserves safe broker diagnostics after request validation", () => { + const definitionsEnd = DOCKER_STATE_MUTATION_HELPER_TRANSPORT_BROKER_SOURCE.indexOf( + "\nhelper = sys.argv[1]\n", + ); + expect(definitionsEnd).toBeGreaterThan(0); + const definitions = DOCKER_STATE_MUTATION_HELPER_TRANSPORT_BROKER_SOURCE.slice( + 0, + definitionsEnd, + ); + const probe = `${definitions} +helper = "/definitely-missing/nemoclaw-runtime-state-mutation-control.py" +try: + run_helper("acquire", b"{}\\n") +except (OSError, RuntimeError, UnicodeError, ValueError) as error: + missing_helper = post_validation_failure_code(error) +print(json.dumps({ + "missingHelper": missing_helper, + "permission": post_validation_failure_code(PermissionError()), + "encoding": post_validation_failure_code(UnicodeDecodeError("utf-8", b"x", 0, 1, "invalid")), + "invalidResponse": post_validation_failure_code(ValueError()), + "helperProcess": json.loads(normalize_helper_stderr("acquire", 2, b"raw python error"))["code"], + "helperProtocol": json.loads(normalize_helper_stderr("acquire", 0, b"unexpected stderr"))["code"], + "timeout": json.loads(failure_stderr("acquire", "helper-timeout"))["code"], +}, separators=(",", ":"))) +`; + + expect( + JSON.parse( + execFileSync("python3", ["-I", "-c", probe], { + encoding: "utf8", + timeout: 5_000, + }), + ), + ).toEqual({ + missingHelper: "helper-file-missing", + permission: "transport-permission-denied", + encoding: "transport-response-encoding-invalid", + invalidResponse: "transport-response-invalid", + helperProcess: "helper-process-failed", + helperProtocol: "helper-protocol-stderr", + timeout: "helper-timeout", + }); + }); + it("uses one harness-owned absolute Docker executable", () => { const runtime = harness(); runtime.authority.engine.capture(["version"]); @@ -276,84 +323,33 @@ describe("Docker state mutation owner", () => { "activate", "release", ]); + expect(runtime.supervisorSignals).toEqual(["SIGSTOP", "SIGCONT"]); expect(runtime.lifecycleStore.listUnfinished()).toEqual([]); const helperCalls = runtime.capture.mock.calls.filter(([, args]) => - args.includes("/usr/local/lib/nemoclaw/runtime-state-mutation-control.py"), - ); - expect(helperCalls.map(([, args]) => args.slice(-10))).toEqual([ - [ - "container", - "exec", - "--interactive", - "--user", - "root", - RUNTIME_ID, - "/opt/hermes/.venv/bin/python3", - "-I", - "/usr/local/lib/nemoclaw/runtime-state-mutation-control.py", - "acquire", - ], - [ - "container", - "exec", - "--interactive", - "--user", - "root", - RUNTIME_ID, - "/opt/hermes/.venv/bin/python3", - "-I", - "/usr/local/lib/nemoclaw/runtime-state-mutation-control.py", - "assert", - ], - [ - "container", - "exec", - "--interactive", - "--user", - "root", - RUNTIME_ID, - "/opt/hermes/.venv/bin/python3", - "-I", - "/usr/local/lib/nemoclaw/runtime-state-mutation-control.py", - "rollback", - ], - [ - "container", - "exec", - "--interactive", - "--user", - "root", - RUNTIME_ID, - "/opt/hermes/.venv/bin/python3", - "-I", - "/usr/local/lib/nemoclaw/runtime-state-mutation-control.py", - "activate", - ], - [ - "container", - "exec", - "--interactive", - "--user", - "root", - RUNTIME_ID, - "/opt/hermes/.venv/bin/python3", - "-I", - "/usr/local/lib/nemoclaw/runtime-state-mutation-control.py", - "activate", - ], - [ - "container", - "exec", - "--interactive", - "--user", - "root", - RUNTIME_ID, - "/opt/hermes/.venv/bin/python3", - "-I", - "/usr/local/lib/nemoclaw/runtime-state-mutation-control.py", - "release", - ], + args.includes("--nemoclaw-broker"), + ); + expect(helperCalls.map(([, args]) => args.at(-1))).toEqual([ + "acquire", + "assert", + "rollback", + "activate", + "activate", + "release", ]); + expect( + runtime.capture.mock.calls.filter( + ([, args]) => + args.includes("--interactive") && + args.includes("/usr/local/lib/nemoclaw/runtime-state-mutation-control.py"), + ), + ).toEqual([]); + expect( + runtime.capture.mock.calls.filter( + ([, args]) => + args.includes("--detach") && + args.includes("/usr/local/lib/nemoclaw/runtime-state-mutation-control.py"), + ), + ).toHaveLength(1); expect(helperCalls.map(([, args, timeout]) => [args.at(-1), timeout])).toEqual([ ["acquire", 30_000], ["assert", 30_000], @@ -455,6 +451,30 @@ describe("Docker state mutation owner", () => { expect(runtime.helperActions.slice(-3)).toEqual(["release", "recover", "release"]); }); + it("keeps recovery transport alive until a stopped supervisor is durably resumed", () => { + const runtime = harness({ failResumeOnce: true }); + const fence = runtime.owner.acquire({ ...runtime.context, plan: plan() }); + runtime.owner.rollback(runtime.context, fence); + const proof = runtime.owner.activate(runtime.context, fence); + const completedLedgerSha256 = "e".repeat(64); + + expect(() => + runtime.owner.release(runtime.context, fence, proof, completedLedgerSha256), + ).toThrow("Docker host supervisor resume did not complete successfully"); + expect(runtime.state.supervisorStopped).toBe(true); + expect(runtime.transportBrokerActive()).toBe(true); + expect(runtime.lifecycleStore.listUnfinished()[0]).toMatchObject({ + phase: "completed", + resultSha256: completedLedgerSha256, + }); + + expect(runtime.owner.recover(runtime.context)).toBeNull(); + expect(runtime.supervisorSignals).toEqual(["SIGSTOP", "SIGCONT", "SIGCONT"]); + expect(runtime.state.supervisorStopped).toBe(false); + expect(runtime.transportBrokerActive()).toBe(false); + expect(runtime.lifecycleStore.listUnfinished()).toEqual([]); + }); + it("recovers a durable provider-release receipt without requiring the removed marker", () => { const runtime = harness(); const fence = runtime.owner.acquire({ ...runtime.context, plan: plan() }); @@ -545,6 +565,73 @@ describe("Docker state mutation owner", () => { expect(runtime.lifecycleStore.listUnfinished()[0]?.phase).toBe("fence-established"); }); + it("publishes one content-addressed request after it host-stops managed Hermes (#9485)", () => { + const runtime = harness({ stateMountType: "volume" }); + + const acquired = runtime.owner.acquire({ ...runtime.context, plan: plan() }); + + expect(acquired.providerHandle).toMatch(/^docker-state-mutation-v1:/u); + expect(runtime.state).toMatchObject({ + mountDriver: "local", + mountName: "nemoclaw-hermes-alpha-state", + mountType: "volume", + supervisorStopped: true, + }); + expect(runtime.supervisorSignals).toEqual(["SIGSTOP"]); + const commands = runtime.capture.mock.calls.map(([, args]) => { + const start = args.findIndex((value) => value === "container"); + return start < 0 ? [] : args.slice(start); + }); + const stop = commands.findIndex((args) => args[1] === "kill"); + const broker = commands.findIndex((args) => args[1] === "exec" && args.includes("--detach")); + const publications = commands.filter( + (args) => args[1] === "cp" && args.at(-1)?.endsWith(".acquire.incoming"), + ); + const request = commands.indexOf(publications[0] ?? []); + expect(commands[stop]).toEqual(["container", "kill", "--signal", "SIGSTOP", RUNTIME_ID]); + expect(runtime.capture.mock.calls.find(([, args]) => args[5] === "kill")?.[1]).toEqual([ + "--config", + "/tmp/nemoclaw-docker", + "--host", + "unix:///tmp/nemoclaw-docker.sock", + "container", + "kill", + "--signal", + "SIGSTOP", + RUNTIME_ID, + ]); + expect(broker).toBeGreaterThanOrEqual(0); + expect(stop).toBeGreaterThan(broker); + expect(publications).toHaveLength(1); + expect(request).toBeGreaterThan(stop); + expect(runtime.transportCopySourceModes).toEqual([0o644, 0o644]); + expect( + commands.some( + (args) => + args[1] === "cp" && + (args.at(-1)?.endsWith(".request") || args.at(-1)?.endsWith(".ready")), + ), + ).toBe(false); + expect( + commands.some( + (args) => + args[1] === "exec" && + args.includes("--interactive") && + args.includes("/usr/local/lib/nemoclaw/runtime-state-mutation-control.py"), + ), + ).toBe(false); + }); + + it("replays one signal-terminated helper invocation through the established transport", () => { + const runtime = harness({ signalHelperOnce: true, stateMountType: "volume" }); + + const acquired = runtime.owner.acquire({ ...runtime.context, plan: plan() }); + + expect(acquired.providerHandle).toMatch(/^docker-state-mutation-v1:/u); + expect(runtime.helperActions).toEqual(["acquire", "acquire"]); + expect(runtime.lifecycleStore.listUnfinished()[0]?.phase).toBe("fence-established"); + }); + it("recovers a durable-volume fence when acquire succeeds after its response is lost (#9485)", () => { const runtime = harness({ loseAcquireResponseOnce: true, stateMountType: "volume" }); expect(runtime.state).toMatchObject({ @@ -562,6 +649,8 @@ describe("Docker state mutation owner", () => { expect(recovered?.providerHandle).toMatch(/^docker-state-mutation-v1:/u); expect(runtime.helperActions).toEqual(["acquire", "acquire"]); + expect(runtime.supervisorSignals).toEqual(["SIGSTOP", "SIGSTOP"]); + expect(runtime.state.supervisorStopped).toBe(true); expect(runtime.acquireRequests[1]).toBe(runtime.acquireRequests[0]); expect(runtime.lifecycleStore.listUnfinished()[0]?.phase).toBe("fence-established"); }); @@ -656,6 +745,7 @@ describe("Docker state mutation owner", () => { lifecycleGeneration: runtime.lifecycleGeneration, lifecycleLiveIdentityFingerprint: SANDBOX_FINGERPRINT, runtimeId: RUNTIME_ID, + hostTransportRoot: runtime.root, authority: changedAuthority, engineAuthorityStore: runtime.engineAuthorityStore, lifecycleStore: runtime.lifecycleStore, diff --git a/src/lib/onboard/runtime-provider/docker-state-mutation.ts b/src/lib/onboard/runtime-provider/docker-state-mutation.ts index a288e337b95..8e247efdbd7 100644 --- a/src/lib/onboard/runtime-provider/docker-state-mutation.ts +++ b/src/lib/onboard/runtime-provider/docker-state-mutation.ts @@ -2,11 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { createHash, randomBytes } from "node:crypto"; +import fs from "node:fs"; import path from "node:path"; import type { ContainerEngine, ContainerEngineCommandCapture, + ContainerEngineCommandResult, ContainerEngineOperationScope, } from "../../adapters/container-engine"; import { resolveShieldsStateDir, withShieldsTransitionLock } from "../../shields/transition-lock"; @@ -38,6 +40,7 @@ import { hasActivePersistedEngineStateMutationTarget, loadPersistedEngineStateMutationIntent, type PersistedEngineLifecycleExecutionInput, + type PersistedEngineLifecycleExactCommand, type PersistedEngineLifecycleRecord, type PersistedEngineLifecycleStore, type PersistedEngineStateMutationIntent, @@ -54,6 +57,10 @@ const HELPER_FAST_TIMEOUT_MS = 30_000; const HELPER_ACTIVATION_TIMEOUT_MS = 5 * 60_000; const HELPER_GUARD_TIMEOUT_MS = 15 * 60_000; const INSPECT_TIMEOUT_MS = 15_000; +const SUPERVISOR_SIGNAL_TIMEOUT_MS = 15_000; +const HELPER_TRANSPORT_COMMAND_TIMEOUT_MS = 15_000; +const HELPER_TRANSPORT_POLL_MS = 250; +const HELPER_TRANSPORT_ROOT = "/run/nemoclaw/runtime-state-mutation"; const MAX_HELPER_TRANSPORT_BYTES = 128 * 1024; const MAX_INSPECTION_BYTES = 1024 * 1024; const MAX_MOUNTS = 256; @@ -68,6 +75,280 @@ const LIFECYCLE_GENERATION = /^[A-Za-z0-9][A-Za-z0-9._:/=+-]{0,511}$/u; const MOUNT_NAMESPACE = /^mnt:\[[1-9][0-9]*\]$/u; const POSITIVE_DECIMAL = /^[1-9][0-9]*$/u; const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u; +const helperTransportPoll = new Int32Array(new SharedArrayBuffer(4)); + +export const DOCKER_STATE_MUTATION_HELPER_TRANSPORT_BROKER_SOURCE = String.raw` +import fcntl +import hashlib +import json +import os +import re +import stat +import subprocess +import sys +import time + +ROOT = "/run/nemoclaw/runtime-state-mutation" +MAXIMUM = 128 * 1024 +TIMEOUTS = {"acquire": 30, "assert": 30, "publish": 900, "recover": 900, "rollback": 900, "activate": 300, "release": 300} +IDENTITY = re.compile(r"[a-f0-9]{64}\Z") +INCOMING = re.compile(r"([a-f0-9]{64})\.(acquire|assert|publish|recover|rollback|activate|release)\.incoming\Z") +PUBLICATION_SETTLE_SECONDS = 5 + +def fail(code): + raise RuntimeError(code) + +def directory(path): + metadata = os.lstat(path) + if (not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != 0 or metadata.st_gid != 0 or + stat.S_IMODE(metadata.st_mode) != 0o700): + fail("transport-directory-invalid") + +def atomic(path, payload): + temporary = path + ".tmp-" + str(os.getpid()) + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC, 0o600) + try: + offset = 0 + while offset < len(payload): + written = os.write(descriptor, payload[offset:]) + if written <= 0: + fail("transport-write-failed") + offset += written + os.fsync(descriptor) + finally: + os.close(descriptor) + os.replace(temporary, path) + +def private_file(path): + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK) + try: + before = os.fstat(descriptor) + payload = os.read(descriptor, MAXIMUM + 1) + after = os.fstat(descriptor) + if (not stat.S_ISREG(before.st_mode) or before.st_uid != 0 or before.st_gid != 0 or + stat.S_IMODE(before.st_mode) != 0o600 or before.st_nlink != 1 or + len(payload) > MAXIMUM or os.read(descriptor, 1) or + (before.st_dev, before.st_ino, before.st_mode, before.st_nlink, before.st_uid, + before.st_gid, before.st_size, before.st_mtime_ns, before.st_ctime_ns) != + (after.st_dev, after.st_ino, after.st_mode, after.st_nlink, after.st_uid, + after.st_gid, after.st_size, after.st_mtime_ns, after.st_ctime_ns)): + fail("transport-file-invalid") + return payload + finally: + os.close(descriptor) + +def copied_file(path): + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK) + try: + before = os.fstat(descriptor) + payload = bytearray() + while len(payload) <= MAXIMUM: + chunk = os.read(descriptor, min(64 * 1024, MAXIMUM + 1 - len(payload))) + if not chunk: + break + payload.extend(chunk) + after = os.fstat(descriptor) + if (not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 or len(payload) > MAXIMUM or + (before.st_dev, before.st_ino, before.st_nlink, before.st_uid, before.st_gid, + before.st_size, before.st_mtime_ns, before.st_ctime_ns) != + (after.st_dev, after.st_ino, after.st_nlink, after.st_uid, after.st_gid, + after.st_size, after.st_mtime_ns, after.st_ctime_ns)): + fail("transport-copied-file-invalid") + return bytes(payload) + finally: + os.close(descriptor) + +def response_payload(action, identity, status, stdout, stderr): + return json.dumps({"schemaVersion": 1, "action": action, "identity": identity, + "status": status, "stdout": stdout, "stderr": stderr}, + ensure_ascii=True, separators=(",", ":")).encode("utf-8") + b"\n" + +def failure_stderr(action, code): + return json.dumps({"schemaVersion": 1, "action": action, "status": "failed", "code": code}, + ensure_ascii=True, separators=(",", ":")) + "\n" + +def post_validation_failure_code(error): + if isinstance(error, RuntimeError): + code = str(error) + if code in ("helper-file-missing", "helper-file-invalid", "transport-response-too-large"): + return code + return "transport-runtime-failed" + if isinstance(error, UnicodeError): + return "transport-response-encoding-invalid" + if isinstance(error, FileNotFoundError): + return "transport-resource-missing" + if isinstance(error, PermissionError): + return "transport-permission-denied" + if isinstance(error, OSError): + return "transport-io-failed" + return "transport-response-invalid" + +def normalize_helper_stderr(action, status, stderr): + if not stderr: + return stderr + try: + failure = json.loads(stderr.decode("utf-8", "strict")) + if (isinstance(failure, dict) and failure.get("schemaVersion") == 1 and + failure.get("action") == action and failure.get("status") == "failed" and + isinstance(failure.get("code"), str) and + re.fullmatch(r"[a-z][a-z0-9-]{0,127}", failure["code"]) is not None): + return stderr + except (UnicodeError, ValueError): + pass + code = "helper-process-failed" if status != 0 else "helper-protocol-stderr" + return failure_stderr(action, code).encode("utf-8") + +def publisher_phase_failure(action, stderr): + if action != "publish": + return stderr + try: + failure = json.loads(stderr.decode("utf-8", "strict")) + if (not isinstance(failure, dict) or failure.get("schemaVersion") != 1 or + failure.get("action") != "publish" or failure.get("status") != "failed" or + failure.get("code") != "publisher-guard-failed"): + return stderr + journal = json.loads(private_file( + "/var/lib/nemoclaw/runtime-state-mutation/hermes-publisher.json" + ).decode("utf-8", "strict")) + operation = journal.get("operation") if isinstance(journal, dict) else None + phase = operation.get("phase") if isinstance(operation, dict) else None + if phase not in ("intent", "begun", "state-applied", "top-applied"): + return stderr + failure["code"] = "publisher-guard-" + phase + "-failed" + return (json.dumps(failure, ensure_ascii=True, separators=(",", ":")) + "\n").encode("utf-8") + except (OSError, RuntimeError, UnicodeError, ValueError): + return stderr + +def run_helper(action, request): + try: + metadata = os.lstat(helper) + except FileNotFoundError: + fail("helper-file-missing") + if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0 or metadata.st_gid != 0 or + stat.S_IMODE(metadata.st_mode) & 0o022): + fail("helper-file-invalid") + completed = None + for attempt in range(2): + completed = subprocess.run([sys.executable, "-I", helper, action], input=request, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=TIMEOUTS[action], check=False, + start_new_session=True) + if completed.returncode >= 0: + return completed + # Every helper action is transaction-bound and idempotent. Replay only + # a signal-terminated invocation once; ordinary nonzero exits remain + # authoritative and are never retried. + return completed + +helper = sys.argv[1] +transaction = sys.argv[2] +if IDENTITY.fullmatch(transaction) is None: + fail("transport-transaction-invalid") +os.makedirs(ROOT, mode=0o700, exist_ok=True) +directory(ROOT) +session = os.path.join(ROOT, transaction) +os.makedirs(session, mode=0o700, exist_ok=True) +directory(session) +lock = os.open(os.path.join(session, "broker.lock"), os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600) +try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) +except BlockingIOError: + raise SystemExit(0) +atomic(os.path.join(session, "ready"), (transaction + "\n").encode("ascii")) +pending = {} + +while True: + names = sorted(os.listdir(session)) + if "released" in names and "resumed" in names: + try: + expected = (transaction + "\n").encode("ascii") + if (private_file(os.path.join(session, "released")) == expected and + copied_file(os.path.join(session, "resumed")) == expected): + for name in ("released", "resumed", "ready", "broker.lock"): + try: + os.unlink(os.path.join(session, name)) + except FileNotFoundError: + pass + try: + os.rmdir(session) + except OSError: + pass + raise SystemExit(0) + except (OSError, RuntimeError, UnicodeError, ValueError): + pass + for name in names: + incoming = INCOMING.fullmatch(name) + if incoming is None: + continue + identity, action = incoming.groups() + request_path = os.path.join(session, name) + response_path = os.path.join(session, identity + ".response") + if os.path.exists(response_path): + continue + validated = False + try: + request = copied_file(request_path) + if not request.endswith(b"\n") or hashlib.sha256(request).hexdigest() != identity: + fail("transport-request-invalid") + envelope = json.loads(request.decode("utf-8", "strict")) + if (not isinstance(envelope, dict) or envelope.get("action") != action or + envelope.get("transactionId") != transaction): + fail("transport-request-invalid") + validated = True + pending.pop(name, None) + os.unlink(request_path) + completed = run_helper(action, request) + if len(completed.stdout) > MAXIMUM or len(completed.stderr) > MAXIMUM: + fail("transport-response-too-large") + status = completed.returncode if completed.returncode >= 0 else 128 - completed.returncode + stderr = publisher_phase_failure(action, completed.stderr) + stderr = normalize_helper_stderr(action, status, stderr) + response = response_payload(action, identity, status, + completed.stdout.decode("utf-8", "strict"), stderr.decode("utf-8", "strict")) + except subprocess.TimeoutExpired: + response = response_payload(action, identity, 1, "", failure_stderr(action, "helper-timeout")) + except (OSError, RuntimeError, UnicodeError, ValueError) as error: + if not validated: + first_observed = pending.setdefault(name, time.monotonic()) + if time.monotonic() - first_observed < PUBLICATION_SETTLE_SECONDS: + continue + pending.pop(name, None) + try: + os.unlink(request_path) + except FileNotFoundError: + pass + response = response_payload(action, identity, 1, "", + failure_stderr(action, "transport-request-invalid")) + else: + # Preserve a safe, actionable failure class without returning + # exception text, host paths, or request contents to the caller. + response = response_payload(action, identity, 1, "", + failure_stderr(action, post_validation_failure_code(error))) + atomic(response_path, response) + for name in names: + if not name.endswith(".ack"): + continue + identity = name[:-4] + if IDENTITY.fullmatch(identity) is None: + continue + response_path = os.path.join(session, identity + ".response") + if not os.path.exists(response_path): + continue + try: + response = json.loads(private_file(response_path).decode("utf-8", "strict")) + if copied_file(os.path.join(session, name)) != (identity + "\n").encode("ascii"): + fail("transport-ack-invalid") + successful_release = response.get("action") == "release" and response.get("status") == 0 + for suffix in (".response", ".ack"): + try: + os.unlink(os.path.join(session, identity + suffix)) + except FileNotFoundError: + pass + if successful_release: + atomic(os.path.join(session, "released"), (transaction + "\n").encode("ascii")) + except (OSError, RuntimeError, UnicodeError, ValueError): + pass + time.sleep(0.05) +`; type HelperAction = | "acquire" @@ -164,6 +445,8 @@ export interface ContainerStateMutationOwnerOptions { readonly lifecycleLiveIdentityFingerprint?: string; /** Full immutable container ID. Names and short IDs are not accepted. */ readonly runtimeId: string; + /** Trusted host directory for bounded Docker copy transport files. */ + readonly hostTransportRoot: string; readonly authority: ContainerStateMutationAuthority; readonly engineAuthorityStore: PersistedEngineAuthorityStore; readonly lifecycleStore: PersistedEngineLifecycleStore; @@ -911,8 +1194,6 @@ function runtimeStateSha256( ["engineBindingSha256", bindingSha256], ["runtimeId", observation.runtimeId], ["runtimePid", observation.runtimePid], - ["pidMode", observation.pidMode], - ["privileged", observation.privileged], ["sandboxIdentitySha256", observation.sandboxIdentitySha256], ["containerMountsSha256", observation.containerMountsSha256], ["stateRoot", stateRoot.stateRoot], @@ -1016,6 +1297,362 @@ function helperCommand(runtimeId: string, action: HelperAction) { }); } +type HelperTransportCapture = ( + command: PersistedEngineLifecycleExactCommand, + timeoutMs: number, +) => ContainerEngineCommandResult; + +function helperTransportSessionPath(transactionId: string): string { + return `${HELPER_TRANSPORT_ROOT}/${transactionId}`; +} + +function helperTransportBrokerCommand( + runtimeId: string, + transactionId: string, +): PersistedEngineLifecycleExactCommand { + return Object.freeze({ + args: Object.freeze([ + "container", + "exec", + "--detach", + "--user", + "root", + runtimeId, + HELPER_PYTHON_PATH, + "-I", + "-c", + "import base64,sys;source=base64.b64decode(sys.argv.pop(1));exec(compile(source,'','exec'))", + Buffer.from(DOCKER_STATE_MUTATION_HELPER_TRANSPORT_BROKER_SOURCE, "utf8").toString("base64"), + HELPER_PATH, + transactionId, + ]), + targetIndex: 5, + }); +} + +function helperTransportCopyToCommand( + runtimeId: string, + hostPath: string, + containerPath: string, +): PersistedEngineLifecycleExactCommand { + return Object.freeze({ + args: Object.freeze(["container", "cp", hostPath, `${runtimeId}:${containerPath}`]), + targetIndex: 3, + targetPath: containerPath, + }); +} + +function helperTransportCopyFromCommand( + runtimeId: string, + containerPath: string, + hostPath: string, +): PersistedEngineLifecycleExactCommand { + return Object.freeze({ + args: Object.freeze(["container", "cp", `${runtimeId}:${containerPath}`, hostPath]), + targetIndex: 2, + targetPath: containerPath, + }); +} + +function helperTransportHostParent(hostRoot: string): string { + const parent = path.join(hostRoot, "runtime-state-mutation-transport"); + fs.mkdirSync(parent, { mode: 0o700, recursive: true }); + const metadata = fs.lstatSync(parent); + const expectedUid = process.getuid?.(); + if ( + !metadata.isDirectory() || + metadata.isSymbolicLink() || + (metadata.mode & 0o777) !== 0o700 || + (expectedUid !== undefined && metadata.uid !== expectedUid) + ) { + fail("host helper transport directory is not private"); + } + return parent; +} + +function withHelperTransportHostDirectory(hostRoot: string, run: (root: string) => T): T { + const temporary = fs.mkdtempSync(path.join(helperTransportHostParent(hostRoot), "operation-")); + fs.chmodSync(temporary, 0o700); + try { + return run(temporary); + } finally { + fs.rmSync(temporary, { force: true, recursive: true }); + } +} + +function writePrivateTransportFile(filePath: string, value: Buffer): void { + // Docker can preserve the invoking host UID on copied files. The enclosing + // transport directories remain private (0700), while this copy source must be + // readable by the capability-restricted broker after publication. + const descriptor = fs.openSync(filePath, "wx", 0o644); + try { + fs.writeFileSync(descriptor, value); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function copyHelperTransportFile( + capture: HelperTransportCapture, + command: PersistedEngineLifecycleExactCommand, +): ContainerEngineCommandResult { + return capture(command, HELPER_TRANSPORT_COMMAND_TIMEOUT_MS); +} + +function readHelperTransportFile( + capture: HelperTransportCapture, + runtimeId: string, + containerPath: string, + hostRoot: string, + timeoutMs: number, +): Buffer { + return withHelperTransportHostDirectory(hostRoot, (temporary) => { + const destination = path.join(temporary, "response"); + const deadline = Date.now() + timeoutMs; + while (true) { + fs.rmSync(destination, { force: true }); + const result = copyHelperTransportFile( + capture, + helperTransportCopyFromCommand(runtimeId, containerPath, destination), + ); + if (!result.error && result.status === 0 && result.stderr.length === 0) { + const value = fs.readFileSync(destination); + if (value.byteLength > MAX_HELPER_TRANSPORT_BYTES) { + fail("root helper transport response exceeds its byte bound"); + } + return value; + } + if (Date.now() >= deadline) fail("root helper transport response did not arrive"); + Atomics.wait(helperTransportPoll, 0, 0, HELPER_TRANSPORT_POLL_MS); + } + }); +} + +function probeHelperTransport( + capture: HelperTransportCapture, + options: ContainerStateMutationOwnerOptions, + transactionId: string, +): boolean { + return withHelperTransportHostDirectory(options.hostTransportRoot, (temporary) => { + const destination = path.join(temporary, "ready"); + const result = copyHelperTransportFile( + capture, + helperTransportCopyFromCommand( + options.runtimeId, + `${helperTransportSessionPath(transactionId)}/ready`, + destination, + ), + ); + if (result.error || result.status !== 0 || result.stderr.length !== 0) return false; + const ready = fs.readFileSync(destination); + if (ready.byteLength > MAX_HELPER_TRANSPORT_BYTES) { + fail("root helper transport readiness response exceeds its byte bound"); + } + return ready.equals(Buffer.from(`${transactionId}\n`, "ascii")); + }); +} + +function ensureHelperTransportAuthorized( + scope: AuthorizedPersistedEngineLifecycle, + options: ContainerStateMutationOwnerOptions, + transactionId: string, +): void { + const capture: HelperTransportCapture = (command, timeoutMs) => + scope.captureExact("target", () => command, timeoutMs); + if (probeHelperTransport(capture, options, transactionId)) return; + requireCommandSuccess( + capture( + helperTransportBrokerCommand(options.runtimeId, transactionId), + HELPER_TRANSPORT_COMMAND_TIMEOUT_MS, + ), + "root helper transport startup", + ); + let ready: Buffer; + try { + ready = readHelperTransportFile( + capture, + options.runtimeId, + `${helperTransportSessionPath(transactionId)}/ready`, + options.hostTransportRoot, + HELPER_TRANSPORT_COMMAND_TIMEOUT_MS, + ); + } catch { + fail("root helper transport did not become available"); + } + if (!ready.equals(Buffer.from(`${transactionId}\n`, "ascii"))) { + fail("root helper transport identity changed"); + } +} + +function finishReleasedHelperTransport( + options: ContainerStateMutationOwnerOptions, + bindingSha256: string, + transactionId: string, +): void { + if (options.providerId !== DOCKER_PROVIDER_ID) return; + const capture: HelperTransportCapture = (command, timeoutMs) => { + requireCurrentEngineAuthority(options, bindingSha256); + const result = options.authority.engine.capture(command.args, timeoutMs); + requireCurrentEngineAuthority(options, bindingSha256); + return result; + }; + if (!probeHelperTransport(capture, options, transactionId)) return; + withHelperTransportHostDirectory(options.hostTransportRoot, (temporary) => { + const resumed = path.join(temporary, "resumed"); + writePrivateTransportFile(resumed, Buffer.from(`${transactionId}\n`, "ascii")); + requireCommandSuccess( + copyHelperTransportFile( + capture, + helperTransportCopyToCommand( + options.runtimeId, + resumed, + `${helperTransportSessionPath(transactionId)}/resumed`, + ), + ), + "root helper transport release finalization", + ); + }); +} + +function parseHelperTransportResult( + value: Buffer, + action: HelperAction, + identity: string, +): ContainerEngineCommandResult { + let parsed: unknown; + try { + parsed = JSON.parse(value.toString("utf8")); + } catch { + fail("root helper transport response is malformed"); + } + const response = record(parsed, "root helper transport response"); + exactKeys( + response, + ["schemaVersion", "action", "identity", "status", "stdout", "stderr"], + "root helper transport response", + ); + if ( + response.schemaVersion !== 1 || + response.action !== action || + response.identity !== identity || + !Number.isSafeInteger(response.status) || + (response.status as number) < 0 || + typeof response.stdout !== "string" || + typeof response.stderr !== "string" || + Buffer.byteLength(response.stdout, "utf8") > MAX_HELPER_TRANSPORT_BYTES || + Buffer.byteLength(response.stderr, "utf8") > MAX_HELPER_TRANSPORT_BYTES + ) { + fail("root helper transport response is malformed"); + } + return { + status: response.status as number, + stdout: response.stdout, + stderr: response.stderr, + }; +} + +function helperFailureCode(stderr: string, action: HelperAction): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(stderr); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + const failure = parsed as Record; + return failure.schemaVersion === 1 && + failure.action === action && + failure.status === "failed" && + typeof failure.code === "string" && + /^[a-z][a-z0-9-]{0,127}$/u.test(failure.code) + ? failure.code + : null; +} + +function requireHelperSuccess(result: ContainerEngineCommandResult, action: HelperAction): string { + if (result.error || result.status !== 0 || result.stderr.length !== 0) { + const code = helperFailureCode(result.stderr, action); + fail(`root helper ${action} did not complete successfully${code ? `: ${code}` : ""}`); + } + return result.stdout; +} + +function invokeHelperTransport( + capture: HelperTransportCapture, + options: ContainerStateMutationOwnerOptions, + transactionId: string, + action: HelperAction, + input: Buffer, +): DockerStateMutationHelperReceipt { + const identity = createHash("sha256").update(input).digest("hex"); + const sessionPath = helperTransportSessionPath(transactionId); + const result = withHelperTransportHostDirectory(options.hostTransportRoot, (temporary) => { + const request = path.join(temporary, "request"); + writePrivateTransportFile(request, input); + requireCommandSuccess( + copyHelperTransportFile( + capture, + helperTransportCopyToCommand( + options.runtimeId, + request, + `${sessionPath}/${identity}.${action}.incoming`, + ), + ), + "root helper transport request publication", + ); + const response = readHelperTransportFile( + capture, + options.runtimeId, + `${sessionPath}/${identity}.response`, + options.hostTransportRoot, + helperTimeoutMs(action) + HELPER_TRANSPORT_COMMAND_TIMEOUT_MS, + ); + const parsed = parseHelperTransportResult(response, action, identity); + const acknowledgement = path.join(temporary, "ack"); + writePrivateTransportFile(acknowledgement, Buffer.from(`${identity}\n`, "ascii")); + requireCommandSuccess( + copyHelperTransportFile( + capture, + helperTransportCopyToCommand( + options.runtimeId, + acknowledgement, + `${sessionPath}/${identity}.ack`, + ), + ), + "root helper transport response acknowledgement", + ); + return parsed; + }); + return parseHelperReceipt(requireHelperSuccess(result, action), options.providerId); +} + +function supervisorSignalCommand(runtimeId: string, requestedSignal: "SIGSTOP" | "SIGCONT") { + return Object.freeze({ + args: Object.freeze(["container", "kill", "--signal", requestedSignal, runtimeId]), + targetIndex: 4, + }); +} + +function signalSupervisorAuthorized( + scope: AuthorizedPersistedEngineLifecycle, + options: ContainerStateMutationOwnerOptions, + requestedSignal: "SIGSTOP" | "SIGCONT", +): void { + // PID-namespace init can only be stopped from an ancestor namespace. Keep + // that one lifecycle operation on the authority-bound engine endpoint and + // expose neither a caller-authored signal nor a caller-authored command. + const result = scope.captureExact( + "target", + (runtimeId) => supervisorSignalCommand(runtimeId, requestedSignal), + SUPERVISOR_SIGNAL_TIMEOUT_MS, + ); + requireCommandSuccess( + result, + `${options.providerDisplayName} host supervisor ${requestedSignal === "SIGSTOP" ? "stop" : "resume"}`, + ); +} + function requireCommandSuccess( result: { readonly status: number; @@ -1094,17 +1731,25 @@ function helperInput(fields: readonly (readonly [string, unknown])[]): Buffer { function invokeHelperAuthorized( scope: AuthorizedPersistedEngineLifecycle, - providerId: string, + options: ContainerStateMutationOwnerOptions, action: HelperAction, input: Buffer, ): DockerStateMutationHelperReceipt { + if (options.providerId === DOCKER_PROVIDER_ID) { + const capture: HelperTransportCapture = (command, timeoutMs) => + scope.captureExact("target", () => command, timeoutMs); + return invokeHelperTransport(capture, options, scope.record.transactionId, action, input); + } const result = scope.captureExact( "target", (runtimeId) => helperCommand(runtimeId, action), helperTimeoutMs(action), input, ); - return parseHelperReceipt(requireCommandSuccess(result, `root helper ${action}`), providerId); + return parseHelperReceipt( + requireCommandSuccess(result, `root helper ${action}`), + options.providerId, + ); } function lifecycleInput( @@ -1507,9 +2152,13 @@ function acquireAuthorizedReceipt( ) { fail("persisted state mutation intent does not match the lifecycle transaction"); } + if (options.providerId === DOCKER_PROVIDER_ID) { + ensureHelperTransportAuthorized(scope, options, exactTransactionId); + } + signalSupervisorAuthorized(scope, options, "SIGSTOP"); const receipt = invokeHelperAuthorized( scope, - options.providerId, + options, "acquire", acquireRequest(options, bindingSha256, observation, stateRoot, plan, nonce, exactTransactionId), ); @@ -1551,23 +2200,41 @@ function queryEstablishedReceipt( } } guard(); - const result = options.authority.engine.capture( - helperCommand(options.runtimeId, action).args, - helperTimeoutMs(action), - statusRequest( - action, - options, - bindingSha256, - before, - execution.transactionId, - expectedFence?.providerHandle, - ), + const request = statusRequest( + action, + options, + bindingSha256, + before, + execution.transactionId, + expectedFence?.providerHandle, ); + const result = + options.providerId === DOCKER_PROVIDER_ID + ? invokeHelperTransport( + (command, timeoutMs) => { + guard(); + const captured = options.authority.engine.capture(command.args, timeoutMs); + guard(); + return captured; + }, + options, + execution.transactionId, + action, + request, + ) + : parseHelperReceipt( + requireCommandSuccess( + options.authority.engine.capture( + helperCommand(options.runtimeId, action).args, + helperTimeoutMs(action), + request, + ), + `root helper ${action}`, + ), + options.providerId, + ); guard(); - const receipt = parseHelperReceipt( - requireCommandSuccess(result, `root helper ${action}`), - options.providerId, - ); + const receipt = result; validateReceipt(receipt, options, bindingSha256, before, currentRecord); if (expectedFence) requireFenceReceipt(expectedFence, receipt); const after = inspectDirect(options, bindingSha256); @@ -1640,7 +2307,7 @@ function releaseAuthorizedFence( const before = inspectAuthorized(scope, options); const receipt = invokeHelperAuthorized( scope, - options.providerId, + options, "release", statusRequest( "release", @@ -1656,6 +2323,7 @@ function releaseAuthorizedFence( validateReceipt(receipt, options, bindingSha256, before, scope.record); requireFenceReceipt(fence, receipt); sameActivationProof(proof, activationProofFromReceipt(receipt, fence.providerHandle)); + signalSupervisorAuthorized(scope, options, "SIGCONT"); const after = inspectAuthorized(scope, options); sameObservation(before, after); } @@ -1844,6 +2512,7 @@ export function createContainerStateMutationOwner( completedLedgerSha256, ) ) { + finishReleasedHelperTransport(options, bindingSha256, record.transactionId); options.lifecycleStore.retire(record.transactionId, completedLedgerSha256); return; } @@ -1862,6 +2531,7 @@ export function createContainerStateMutationOwner( completedLedgerSha256, ); }); + finishReleasedHelperTransport(options, bindingSha256, record.transactionId); options.lifecycleStore.retire(record.transactionId, completedLedgerSha256); return; } @@ -1876,7 +2546,7 @@ export function createContainerStateMutationOwner( const before = inspectAuthorized(scope, options); const receipt = invokeHelperAuthorized( scope, - options.providerId, + options, "activate", statusRequest( "activate", @@ -1909,6 +2579,7 @@ export function createContainerStateMutationOwner( ); }, ); + finishReleasedHelperTransport(options, bindingSha256, record.transactionId); options.lifecycleStore.retire(record.transactionId, completedLedgerSha256); }, @@ -1925,7 +2596,7 @@ export function createContainerStateMutationOwner( const before = inspectAuthorized(scope, options); const recovered = invokeHelperAuthorized( scope, - options.providerId, + options, "recover", statusRequest("recover", options, bindingSha256, before, record.transactionId), ); @@ -1948,6 +2619,7 @@ export function createContainerStateMutationOwner( completed.resultSha256 as string, ); }); + finishReleasedHelperTransport(options, bindingSha256, record.transactionId); options.lifecycleStore.retire(record.transactionId, record.resultSha256); return null; } @@ -2095,6 +2767,7 @@ function createSurfaceOwner( lifecycleLiveIdentityFingerprint: input.sandbox.lifecycleLiveIdentityFingerprint, }), runtimeId, + hostTransportRoot: stateDir, authority, engineAuthorityStore, lifecycleStore: createFilePersistedEngineLifecycleStore(stateDir), diff --git a/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.test.ts b/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.test.ts index e2a7e839ece..b06030d4810 100644 --- a/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.test.ts +++ b/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.test.ts @@ -1377,12 +1377,13 @@ describe("persisted engine lifecycle", () => { await expect( executePersistedEngineLifecycle(runtime.input, (scope) => { scope.captureExact("source", (runtimeId) => ({ - args: ["rm", "other-runtime", "--authorized-id", runtimeId], - targetIndex: 1, + args: ["container", "cp", `${runtimeId}:/run/nemoclaw/other`, "/tmp/receipt"], + targetIndex: 2, + targetPath: "/run/nemoclaw/receipt", })); return { resultSha256: RESULT_SHA256, value: undefined }; }), - ).rejects.toThrow("target must be its persisted runtime ID"); + ).rejects.toThrow("another persisted runtime target"); expect(capture).not.toHaveBeenCalled(); }); diff --git a/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.ts b/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.ts index 6497f61c7ec..52c729f17fd 100644 --- a/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.ts +++ b/src/lib/onboard/runtime-provider/persisted-engine-lifecycle.ts @@ -165,6 +165,8 @@ export interface PersistedEngineLifecycleExactCommand { readonly args: readonly string[]; /** Index containing the command's one exact persisted runtime target. */ readonly targetIndex: number; + /** Fixed absolute container path when the command targets `runtimeId:path`. */ + readonly targetPath?: string; } export interface AuthorizedPersistedEngineLifecycle { @@ -1958,11 +1960,12 @@ function exactArguments( command: PersistedEngineLifecycleExactCommand, runtimeId: string, ): readonly string[] { + if (typeof command !== "object" || command === null || Array.isArray(command)) { + throw new Error("Exact runtime command has an invalid argument count."); + } + const commandKeys = Object.keys(command).sort().join(","); if ( - typeof command !== "object" || - command === null || - Array.isArray(command) || - Object.keys(command).sort().join(",") !== "args,targetIndex" || + (commandKeys !== "args,targetIndex" && commandKeys !== "args,targetIndex,targetPath") || !Array.isArray(command.args) || command.args.length === 0 || command.args.length > MAX_ARGUMENTS || @@ -1972,6 +1975,19 @@ function exactArguments( ) { throw new Error("Exact runtime command has an invalid argument count."); } + const targetPath = command.targetPath; + if ( + targetPath !== undefined && + (typeof targetPath !== "string" || + !targetPath.startsWith("/") || + path.posix.normalize(targetPath) !== targetPath || + targetPath.includes(":") || + CONTROL_CHARACTERS.test(targetPath) || + Buffer.byteLength(targetPath, "utf8") > MAX_ARGUMENT_BYTES) + ) { + throw new Error("Exact runtime command container path is invalid."); + } + const exactTarget = targetPath === undefined ? runtimeId : `${runtimeId}:${targetPath}`; let exactRuntimeReferences = 0; const normalized = command.args.map((value, index) => { if ( @@ -1981,13 +1997,18 @@ function exactArguments( ) { throw new Error(`Exact runtime command argument ${String(index)} is invalid.`); } - if (value === runtimeId) exactRuntimeReferences += 1; + if (value === runtimeId || value.startsWith(`${runtimeId}:`)) { + if (value !== exactTarget) { + throw new Error("Exact runtime command contains another persisted runtime target."); + } + exactRuntimeReferences += 1; + } return value; }); if (exactRuntimeReferences !== 1) { throw new Error("Exact runtime command must contain its persisted runtime ID exactly once."); } - if (normalized[command.targetIndex] !== runtimeId) { + if (normalized[command.targetIndex] !== exactTarget) { throw new Error("Exact runtime command target must be its persisted runtime ID."); } return Object.freeze(normalized); diff --git a/src/lib/onboard/sandbox-create-intent-types.ts b/src/lib/onboard/sandbox-create-intent-types.ts index 15d03f1515b..743e4b6f690 100644 --- a/src/lib/onboard/sandbox-create-intent-types.ts +++ b/src/lib/onboard/sandbox-create-intent-types.ts @@ -100,7 +100,7 @@ export type MaterializeSandboxCreatePlanInput = { runProviderPreDeleteCleanup(): void; upsertMessagingProviders( tokenDefs: MessagingTokenDef[], - options: { replaceExisting: true }, + options: { replaceExisting: true; allowedSandboxes: readonly [string] }, ): string[]; getHermesToolGatewayProviderName(sandboxName: string): string; discloseInitialSandboxPolicy?(policy: InitialSandboxPolicy): void; diff --git a/src/lib/onboard/sandbox-create-launch.ts b/src/lib/onboard/sandbox-create-launch.ts index ea5b3e3cc32..b0ca4f33a50 100644 --- a/src/lib/onboard/sandbox-create-launch.ts +++ b/src/lib/onboard/sandbox-create-launch.ts @@ -91,7 +91,7 @@ export function renderSandboxCreateCommand( ])} 2>&1`; } -function managedBootstrapCreateArgs( +export function managedBootstrapCreateArgs( createArgs: readonly string[], bootstrapIdentity: string | null, ): string[] { diff --git a/src/lib/onboard/sandbox-create-plan-materialization.ts b/src/lib/onboard/sandbox-create-plan-materialization.ts index b72889cbe61..d59133d8416 100644 --- a/src/lib/onboard/sandbox-create-plan-materialization.ts +++ b/src/lib/onboard/sandbox-create-plan-materialization.ts @@ -269,7 +269,10 @@ export function materializeSandboxCreatePlan({ const messagingProviders = filterDisabledMessagingProviders( [ ...new Set([ - ...upsertMessagingProviders(enabledMessagingTokenDefs, { replaceExisting: true }), + ...upsertMessagingProviders(enabledMessagingTokenDefs, { + replaceExisting: true, + allowedSandboxes: [intent.sandboxName], + }), ...intent.reusableMessagingProviders, ]), ], diff --git a/src/lib/onboard/sandbox-create-plan.test.ts b/src/lib/onboard/sandbox-create-plan.test.ts index c21862a3c03..fe736607334 100644 --- a/src/lib/onboard/sandbox-create-plan.test.ts +++ b/src/lib/onboard/sandbox-create-plan.test.ts @@ -331,9 +331,13 @@ describe("resolveSandboxCreateIntent", () => { expect(policy.appliedPresets).toEqual(["telegram"]); }, runProviderPreDeleteCleanup: () => events.push("cleanup"), - upsertMessagingProviders: vi.fn((receivedTokenDefs) => { + upsertMessagingProviders: vi.fn((receivedTokenDefs, options) => { events.push("upsert"); expect(receivedTokenDefs).toEqual(tokenDefs); + expect(options).toEqual({ + replaceExisting: true, + allowedSandboxes: ["sandbox"], + }); return ["sandbox-telegram-bridge"]; }), getHermesToolGatewayProviderName: (sandboxName) => { diff --git a/src/lib/onboard/sandbox-create/orchestration.test.ts b/src/lib/onboard/sandbox-create/orchestration.test.ts index 65de4f73dff..1b2bcdf5aa2 100644 --- a/src/lib/onboard/sandbox-create/orchestration.test.ts +++ b/src/lib/onboard/sandbox-create/orchestration.test.ts @@ -7,10 +7,47 @@ import type { SandboxEntry } from "../../state/registry"; import { applyAbsentSandboxRebuildPolicyCarryForward, completeHermesPortableSandboxRegistration, + hasManagedMcpRebuildHandoff, proveRecreateSourceBeforePolicyCarryForward, readManagedDcodeCreateSelectionDrift, } from "./orchestration"; +describe("managed MCP rebuild handoff", () => { + const targetIntentFingerprint = "a".repeat(64); + const recreateTransaction = { + id: "recreate-1", + targetGeneration: "generation-1", + targetIntentFingerprint, + }; + + it("accepts only a handoff bound to the same recreate transaction", () => { + expect( + hasManagedMcpRebuildHandoff({ + recreate: true, + toolDisclosure: "progressive", + observabilityEnabled: false, + recreateJournalTargetIntentFingerprint: targetIntentFingerprint, + recreateTransaction, + }), + ).toBe(true); + }); + + it.each([ + ["missing", undefined], + ["mismatched", "b".repeat(64)], + ])("rejects a %s outer rebuild handoff", (_label, handoff) => { + expect( + hasManagedMcpRebuildHandoff({ + recreate: true, + toolDisclosure: "progressive", + observabilityEnabled: false, + ...(handoff ? { recreateJournalTargetIntentFingerprint: handoff } : {}), + recreateTransaction, + }), + ).toBe(false); + }); +}); + describe("authoritative rebuild policy carry-forward", () => { it("proves the journaled source before mutating its preserved policy row (#9792)", () => { const events: string[] = []; diff --git a/src/lib/onboard/sandbox-create/orchestration.ts b/src/lib/onboard/sandbox-create/orchestration.ts index bb3286a6629..5a00d38a9bd 100644 --- a/src/lib/onboard/sandbox-create/orchestration.ts +++ b/src/lib/onboard/sandbox-create/orchestration.ts @@ -15,6 +15,10 @@ import type { SandboxGpuConfig } from "../sandbox-gpu-mode"; import type { PortableOnboardRuntimeContext } from "../session-bootstrap"; import type { InferenceRouteReservationAuthority, SandboxCreateIntent } from "../types"; import * as sandboxCreatePlanMaterialization from "../sandbox-create-plan-materialization"; +import { + publishAttachedProvidersBeforeDockerSandboxCreation, + validateAttachedMessagingProvidersBeforeSandboxCreation, +} from "./provider-publication"; type SandboxRecreateReasonInput = { sandboxName: string; @@ -109,49 +113,27 @@ export async function completeHermesPortableSandboxRegistration(input: { return registered; } -function publishAttachedProvidersBeforeDockerSandboxCreation( - input: { - readonly openshellDriver: SandboxEntry["openshellDriver"]; - readonly inferenceProvider: string | null; - readonly messagingProviders: readonly string[]; - readonly extraProviders: readonly string[]; - readonly gatewayName: string; - }, - deps: Pick & { - readonly cleanupCreateSources: () => void; - }, -): void { - if (input.openshellDriver === "docker") { - const providersRequiringExistenceProbe = new Set( - [input.inferenceProvider, ...input.messagingProviders].filter( - (provider): provider is string => Boolean(provider), - ), - ); - const attachedProviders = new Set([ - ...providersRequiringExistenceProbe, - ...input.extraProviders, - ]); - for (const attachedProvider of attachedProviders) { - if ( - providersRequiringExistenceProbe.has(attachedProvider) && - !deps.providerExistsInGateway(attachedProvider) - ) - continue; - const refreshed = deps.runOpenshell( - ["provider", "update", "-g", input.gatewayName, attachedProvider], - { - ignoreError: true, - suppressOutput: true, - }, - ); - if (refreshed.status !== 0) { - deps.cleanupCreateSources(); - throw new Error( - `OpenShell did not publish attached provider '${attachedProvider}' before Docker sandbox creation.`, - ); - } - } - } +export function hasManagedMcpRebuildHandoff( + createIntent: SandboxCreateIntent | null | undefined, +): boolean { + const handoff = createIntent?.recreateJournalTargetIntentFingerprint; + return Boolean( + handoff && createIntent?.recreateTransaction?.targetIntentFingerprint === handoff, + ); +} + +function shouldRefuseManagedMcpRecreate( + preservedMcpState: unknown, + managedMcpRebuildHandoff: boolean, +): boolean { + return Boolean(preservedMcpState) && !managedMcpRebuildHandoff; +} + +function hasPreservedManagedMcpRebuildHandoff( + preservedMcpState: unknown, + createIntent: SandboxCreateIntent | null | undefined, +): boolean { + return Boolean(preservedMcpState) && hasManagedMcpRebuildHandoff(createIntent); } type ApplyRecreatePolicyCarryForward = ( @@ -502,7 +484,13 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche { computePlan, managedWorkloadRebuild, - tempManagedRuntime, + tempManagedRuntime: + tempManagedRuntime || + managedWorkloadOnboard.shouldActivateStockManagedRuntime({ + portableLifecycle: sandboxGpuCreateFlow.resolvePortableLifecycleMode(agent), + hermesPortableLifecycle: agentCreateInput.hermesPortableLifecycle, + agentName: requestedAgentName, + }), tempManagedRuntimeCatalog, agentName: requestedAgentName, legacyDockerfilePath, @@ -823,7 +811,11 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche }, { formatSandboxAgentName, note }, ); - if (preservedMcpState) { + const managedMcpRebuildHandoff = hasPreservedManagedMcpRebuildHandoff( + preservedMcpState, + createIntent, + ); + if (shouldRefuseManagedMcpRecreate(preservedMcpState, managedMcpRebuildHandoff)) { for (const hint of recreateJournal.managedMcpRecreateRefusalHints({ sandboxName, cliName: cliName(), @@ -1207,6 +1199,27 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche sandboxGpuEnabled: effectiveSandboxGpuConfig.sandboxGpuEnabled, }); + const providerPreparationInput = { + openshellDriver: sandboxRuntimeFields.openshellDriver, + inferenceProvider: resolvedCreateIntent.inferenceProvider, + messagingProviders, + messagingProviderRequests: resolvedCreateIntent.messagingProviderRequests, + extraProviders: resolvedCreateIntent.extraProviders, + gatewayName: GATEWAY_NAME, + }; + const providerPreparationDeps = { + providerExistsInGateway, + runOpenshell, + cleanupCreateSources: () => { + cleanupInitialCreateSource(); + cleanupBuildContext(); + }, + }; + validateAttachedMessagingProvidersBeforeSandboxCreation( + providerPreparationInput, + providerPreparationDeps, + ); + if (hermesPortableAuthority) { if (!portableRuntimeContext?.environmentScope) { throw new Error("Hermes portable onboarding is missing runtime environment authority."); @@ -1284,21 +1297,8 @@ export function createSandboxWithBaseImageResolution(runtime: SandboxCreateOrche cleanupBuildContext(); } else { publishAttachedProvidersBeforeDockerSandboxCreation( - { - openshellDriver: sandboxRuntimeFields.openshellDriver, - inferenceProvider: resolvedCreateIntent.inferenceProvider, - messagingProviders, - extraProviders: resolvedCreateIntent.extraProviders, - gatewayName: GATEWAY_NAME, - }, - { - providerExistsInGateway, - runOpenshell, - cleanupCreateSources: () => { - cleanupInitialCreateSource(); - cleanupBuildContext(); - }, - }, + providerPreparationInput, + providerPreparationDeps, ); const created = await runCreateFlow(createArgv); cleanupInitialCreateSource(); diff --git a/src/lib/onboard/sandbox-create/provider-publication.test.ts b/src/lib/onboard/sandbox-create/provider-publication.test.ts new file mode 100644 index 00000000000..741f0a9e8ae --- /dev/null +++ b/src/lib/onboard/sandbox-create/provider-publication.test.ts @@ -0,0 +1,246 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { MESSAGING_CREDENTIAL_PROVIDER_TYPE } from "../../messaging/provider-profile"; +import { + publishAttachedProvidersBeforeDockerSandboxCreation, + validateAttachedMessagingProvidersBeforeSandboxCreation, +} from "./provider-publication"; + +type ProviderState = { + type: string; + credentialKey: string; + configKeys: string; +}; + +const providerName = "my-assistant-telegram-bridge"; +const exactState: ProviderState = { + type: MESSAGING_CREDENTIAL_PROVIDER_TYPE, + credentialKey: "TELEGRAM_BOT_TOKEN", + configKeys: "", +}; + +function providerOutput(name: string, state: ProviderState): string { + return [ + `Name: ${name}`, + `Type: ${state.type}`, + `Credential keys: ${state.credentialKey}`, + `Config keys: ${state.configKeys}`, + "", + ].join("\n"); +} + +function createHarness( + initialState: ProviderState | null = exactState, + postUpdateState: ProviderState = initialState || exactState, + profileImportResult = { status: 0, stdout: "", stderr: "" }, + profileExportResult = { status: 0, stdout: "", stderr: "" }, +) { + let updated = false; + const cleanupCreateSources = vi.fn(); + const providerExistsInGateway = vi.fn(() => true); + const runOpenshell = vi.fn((args: string[]) => { + switch (`${args[0]} ${args[1]}`) { + case "provider profile": + return args.includes("import") ? profileImportResult : profileExportResult; + case "provider get": + return initialState + ? { + status: 0, + stdout: providerOutput(args.at(-1) || "", updated ? postUpdateState : initialState), + } + : { status: 2, stderr: "transport unavailable" }; + case "provider update": + updated = true; + return { status: 0 }; + default: + return { status: 0 }; + } + }); + + return { + cleanupCreateSources, + providerExistsInGateway, + runOpenshell, + deps: { + cleanupCreateSources, + providerExistsInGateway, + runOpenshell, + } as unknown as Parameters[1], + }; +} + +function publicationInput( + overrides: Partial< + Parameters[0] + > = {}, +): Parameters[0] { + return { + openshellDriver: "docker", + inferenceProvider: null, + messagingProviders: [providerName], + messagingProviderRequests: [ + { + name: providerName, + envKey: "TELEGRAM_BOT_TOKEN", + providerType: MESSAGING_CREDENTIAL_PROVIDER_TYPE, + credentialConfigured: false, + channel: "telegram", + }, + ], + extraProviders: [], + gatewayName: "nemoclaw", + ...overrides, + }; +} + +function prepareProviders( + input: Parameters[0], + deps: Parameters[1], +): void { + validateAttachedMessagingProvidersBeforeSandboxCreation(input, deps); + publishAttachedProvidersBeforeDockerSandboxCreation(input, deps); +} + +describe("sandbox provider preparation", () => { + it("confirms an exact messaging binding before and after publication (#9875)", () => { + const harness = createHarness(); + + prepareProviders(publicationInput(), harness.deps); + + expect(harness.runOpenshell.mock.calls.map(([args]) => args)).toEqual([ + [ + "provider", + "profile", + "-g", + "nemoclaw", + "import", + "--file", + expect.stringContaining("nemoclaw-mcp-v1.yaml"), + ], + ["provider", "get", "-g", "nemoclaw", providerName], + ["provider", "update", "-g", "nemoclaw", providerName], + ["provider", "get", "-g", "nemoclaw", providerName], + ]); + expect(harness.providerExistsInGateway).not.toHaveBeenCalled(); + expect(harness.cleanupCreateSources).not.toHaveBeenCalled(); + }); + + it.each<{ case: string; state: ProviderState | null }>([ + { + case: "generic provider type", + state: { ...exactState, type: "generic" }, + }, + { + case: "wrong credential key", + state: { ...exactState, credentialKey: "WRONG_TOKEN" }, + }, + { + case: "non-empty configuration", + state: { ...exactState, configKeys: "UNEXPECTED_CONFIG" }, + }, + { + case: "canonical probe ambiguity", + state: null, + }, + ])("rejects $case before publication (#9875)", ({ state }) => { + const harness = createHarness(state); + + expect(() => prepareProviders(publicationInput(), harness.deps)).toThrowError( + `OpenShell did not confirm messaging provider '${providerName}' before sandbox creation.`, + ); + expect(harness.runOpenshell).toHaveBeenCalledTimes(2); + expect(harness.cleanupCreateSources).toHaveBeenCalledOnce(); + }); + + it("rejects a messaging binding that changes during publication (#9875)", () => { + const harness = createHarness(exactState, { ...exactState, type: "generic" }); + + expect(() => prepareProviders(publicationInput(), harness.deps)).toThrowError( + `OpenShell did not confirm messaging provider '${providerName}' after publication.`, + ); + expect(harness.runOpenshell.mock.calls.map(([args]) => args)).toEqual([ + [ + "provider", + "profile", + "-g", + "nemoclaw", + "import", + "--file", + expect.stringContaining("nemoclaw-mcp-v1.yaml"), + ], + ["provider", "get", "-g", "nemoclaw", providerName], + ["provider", "update", "-g", "nemoclaw", providerName], + ["provider", "get", "-g", "nemoclaw", providerName], + ]); + expect(harness.cleanupCreateSources).toHaveBeenCalledOnce(); + }); + + it("preserves publication for providers outside the credential profile (#9875)", () => { + const harness = createHarness(); + const arbitraryProvider = "operator-provider"; + + prepareProviders( + publicationInput({ + messagingProviders: [], + messagingProviderRequests: [], + extraProviders: [arbitraryProvider], + }), + harness.deps, + ); + + expect(harness.runOpenshell.mock.calls.map(([args]) => args)).toEqual([ + ["provider", "update", "-g", "nemoclaw", arbitraryProvider], + ]); + expect(harness.cleanupCreateSources).not.toHaveBeenCalled(); + }); + + it("rejects an incompatible messaging binding before a portable Hermes create (#9875)", () => { + const harness = createHarness({ ...exactState, type: "generic" }); + + expect(() => + validateAttachedMessagingProvidersBeforeSandboxCreation( + publicationInput({ openshellDriver: "native" }), + harness.deps, + ), + ).toThrowError(`OpenShell did not confirm messaging provider '${providerName}'`); + expect(harness.runOpenshell).toHaveBeenCalledTimes(2); + expect(harness.cleanupCreateSources).toHaveBeenCalledOnce(); + }); + + it("rejects an incompatible global messaging profile before provider adoption (#9875)", () => { + const harness = createHarness( + exactState, + exactState, + { status: 1, stdout: "", stderr: "profile already exists" }, + { + status: 0, + stdout: JSON.stringify({ + id: MESSAGING_CREDENTIAL_PROVIDER_TYPE, + credentials: [], + endpoints: ["https://foreign.invalid"], + binaries: [], + inference_capable: false, + }), + stderr: "", + }, + ); + + expect(() => + validateAttachedMessagingProvidersBeforeSandboxCreation(publicationInput(), harness.deps), + ).toThrowError(/does not match NemoClaw's endpointless messaging credential contract/u); + expect( + harness.runOpenshell.mock.calls.some(([args]) => + args.join(" ").startsWith("provider profile -g nemoclaw import"), + ), + ).toBe(true); + expect( + harness.runOpenshell.mock.calls.some( + ([args]) => args.slice(0, 2).join(" ") === "provider update", + ), + ).toBe(false); + expect(harness.cleanupCreateSources).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/onboard/sandbox-create/provider-publication.ts b/src/lib/onboard/sandbox-create/provider-publication.ts new file mode 100644 index 00000000000..2f94763b8e6 --- /dev/null +++ b/src/lib/onboard/sandbox-create/provider-publication.ts @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxCreateOrchestrationRuntime } from "../../onboard"; +import { REPOSITORY_ROOT } from "../../core/repository-root"; +import { + ensureMessagingCredentialProviderProfile, + MESSAGING_CREDENTIAL_PROVIDER_TYPE, +} from "../../messaging/provider-profile"; +import type { SandboxEntry } from "../../state/registry"; +import { inspectGatewayCredentialOnlyProviderBinding } from "../gateway-provider-metadata"; +import type { SandboxCreateIntent } from "../sandbox-create-intent-types"; + +type ProviderPreparationInput = { + readonly openshellDriver: SandboxEntry["openshellDriver"]; + readonly inferenceProvider: string | null; + readonly messagingProviders: readonly string[]; + readonly messagingProviderRequests: SandboxCreateIntent["messagingProviderRequests"]; + readonly extraProviders: readonly string[]; + readonly gatewayName: string; +}; + +type ProviderPreparationDeps = Pick< + SandboxCreateOrchestrationRuntime, + "providerExistsInGateway" | "runOpenshell" +> & { + readonly cleanupCreateSources: () => void; +}; + +function expectedMessagingBindings(input: ProviderPreparationInput) { + return new Map( + input.messagingProviderRequests + .filter(({ providerType }) => providerType === MESSAGING_CREDENTIAL_PROVIDER_TYPE) + .map(({ envKey, name }) => [ + name, + { + name, + type: MESSAGING_CREDENTIAL_PROVIDER_TYPE, + credentialKey: envKey, + }, + ]), + ); +} + +function inspectExpectedMessagingBinding( + input: ProviderPreparationInput, + deps: ProviderPreparationDeps, + providerName: string, + expectedBindings: ReturnType, +): boolean { + const expected = expectedBindings.get(providerName); + if (!expected) return true; + const inspection = inspectGatewayCredentialOnlyProviderBinding(expected, (args, options) => + deps.runOpenshell([...args.slice(0, 2), "-g", input.gatewayName, ...args.slice(2)], options), + ); + return inspection.kind === "exact"; +} + +export function validateAttachedMessagingProvidersBeforeSandboxCreation( + input: ProviderPreparationInput, + deps: ProviderPreparationDeps, +): void { + const expectedBindings = expectedMessagingBindings(input); + const attachedMessagingProviders = [ + ...new Set( + [input.inferenceProvider, ...input.messagingProviders, ...input.extraProviders].filter( + (provider): provider is string => Boolean(provider), + ), + ), + ].filter((name) => expectedBindings.has(name)); + if (attachedMessagingProviders.length === 0) return; + + try { + ensureMessagingCredentialProviderProfile({ + root: REPOSITORY_ROOT, + runOpenshell: (args, options) => + deps.runOpenshell( + [...args.slice(0, 2), "-g", input.gatewayName, ...args.slice(2)], + options, + ), + }); + } catch (error) { + deps.cleanupCreateSources(); + throw error; + } + + for (const providerName of attachedMessagingProviders) { + if (inspectExpectedMessagingBinding(input, deps, providerName, expectedBindings)) continue; + deps.cleanupCreateSources(); + throw new Error( + `OpenShell did not confirm messaging provider '${providerName}' before sandbox creation.`, + ); + } +} + +export function publishAttachedProvidersBeforeDockerSandboxCreation( + input: ProviderPreparationInput, + deps: ProviderPreparationDeps, +): void { + if (input.openshellDriver !== "docker") return; + + const expectedBindings = expectedMessagingBindings(input); + const providersRequiringExistenceProbe = new Set( + [ + input.inferenceProvider, + ...input.messagingProviders.filter((name) => !expectedBindings.has(name)), + ].filter((provider): provider is string => Boolean(provider)), + ); + const attachedProviders = new Set([ + ...providersRequiringExistenceProbe, + ...input.messagingProviders, + ...input.extraProviders, + ]); + for (const attachedProvider of attachedProviders) { + if ( + providersRequiringExistenceProbe.has(attachedProvider) && + !deps.providerExistsInGateway(attachedProvider) + ) + continue; + const refreshed = deps.runOpenshell( + ["provider", "update", "-g", input.gatewayName, attachedProvider], + { + ignoreError: true, + suppressOutput: true, + }, + ); + if (refreshed.status !== 0) { + deps.cleanupCreateSources(); + throw new Error( + `OpenShell did not publish attached provider '${attachedProvider}' before Docker sandbox creation.`, + ); + } + if (inspectExpectedMessagingBinding(input, deps, attachedProvider, expectedBindings)) continue; + deps.cleanupCreateSources(); + throw new Error( + `OpenShell did not confirm messaging provider '${attachedProvider}' after publication.`, + ); + } +} diff --git a/src/lib/onboard/sandbox-gpu-cleanup-verification.test.ts b/src/lib/onboard/sandbox-gpu-cleanup-verification.test.ts index 0902c178ce0..e9ba9d4d30b 100644 --- a/src/lib/onboard/sandbox-gpu-cleanup-verification.test.ts +++ b/src/lib/onboard/sandbox-gpu-cleanup-verification.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { cleanupNativeGpuAttemptForFallback, + cleanupNativeGpuFailureForFallback, type NativeGpuFallbackCleanupResult, } from "./sandbox-gpu-create-attempt"; import { @@ -59,6 +60,71 @@ function scenario({ } describe("cleanupNativeGpuAttemptForFallback", () => { + it("never turns an exact owner-cleanup handoff into a mutable-name delete", () => { + const runOpenshell = vi.fn(); + + const result = cleanupNativeGpuFailureForFallback( + "alpha", + { + ok: false, + route: "native", + stage: "gpu-proof", + error: new Error("native GPU attachment absent"), + fallbackEligible: true, + nativeCleanupHandoff: { + kind: "openshell-owner-cleanup-required", + sandboxName: "alpha", + sandboxId: "sandbox-id-alpha", + runtimeId: "runtime-id-alpha", + }, + }, + { runOpenshell }, + ); + + expect(result).toEqual({ + safe: false, + reason: + "managed bootstrap owner cleanup is required for the exact sandbox and runtime identities", + deleteStatus: null, + sandboxPresent: null, + containerIds: ["runtime-id-alpha"], + }); + expect(runOpenshell).not.toHaveBeenCalled(); + }); + + it("accepts only an exact managed owner-cleanup completion receipt", () => { + const runOpenshell = vi.fn(); + const failure = { + ok: false as const, + route: "native" as const, + stage: "gpu-proof" as const, + error: new Error("native GPU attachment absent"), + fallbackEligible: true, + nativeCleanupReceipt: { + kind: "openshell-owner-cleanup-completed" as const, + sandboxName: "alpha", + sandboxId: "sandbox-id-alpha", + runtimeId: "runtime-id-alpha", + }, + }; + + expect(cleanupNativeGpuFailureForFallback("alpha", failure, { runOpenshell })).toEqual({ + safe: true, + reason: null, + deleteStatus: null, + sandboxPresent: false, + containerIds: [], + }); + expect(cleanupNativeGpuFailureForFallback("renamed", failure, { runOpenshell })).toEqual({ + safe: false, + reason: "managed bootstrap owner cleanup receipt does not match the requested sandbox", + deleteStatus: null, + sandboxPresent: null, + containerIds: ["runtime-id-alpha"], + }); + expect(runOpenshell).not.toHaveBeenCalled(); + }); + it("uses the documented fail-closed cleanup limits by default", () => { const { result, runOpenshell, sleep } = scenario({ list: { status: 0, stdout: "alpha Ready" }, diff --git a/src/lib/onboard/sandbox-gpu-create-attempt.ts b/src/lib/onboard/sandbox-gpu-create-attempt.ts index 752166719b5..c0ca6bf5654 100644 --- a/src/lib/onboard/sandbox-gpu-create-attempt.ts +++ b/src/lib/onboard/sandbox-gpu-create-attempt.ts @@ -8,6 +8,10 @@ import { initialDockerGpuRoute, type SelectedDockerGpuRoute, } from "./docker-gpu-route"; +import type { + ManagedBootstrapNativeGpuFallbackOwnerCleanupHandoff, + ManagedBootstrapNativeGpuFallbackOwnerCleanupReceipt, +} from "./managed-bootstrap/runtime-create"; import { type OpenShellDockerSandboxContainerQuery, queryOpenShellDockerSandboxContainers, @@ -32,6 +36,8 @@ export type SandboxGpuCreateAttemptFailure = { stage: SandboxGpuCreateFailureStage; error: unknown; fallbackEligible: boolean; + nativeCleanupHandoff?: ManagedBootstrapNativeGpuFallbackOwnerCleanupHandoff; + nativeCleanupReceipt?: ManagedBootstrapNativeGpuFallbackOwnerCleanupReceipt; }; export type SandboxGpuCreateAttemptResult = @@ -222,10 +228,53 @@ export function cleanupNativeGpuAttemptForFallback( }; } +/** + * Keep owner-managed runtimes out of the generic mutable-name cleanup path. + * Only the lifecycle's exact owner-cleanup receipt may authorize the retry. + */ +export function cleanupNativeGpuFailureForFallback( + sandboxName: string, + failure: SandboxGpuCreateAttemptFailure, + deps: NativeGpuFallbackCleanupDeps, +): NativeGpuFallbackCleanupResult { + if (failure.nativeCleanupReceipt) { + const receipt = failure.nativeCleanupReceipt; + if (receipt.sandboxName === sandboxName) { + return { + safe: true, + reason: null, + deleteStatus: null, + sandboxPresent: false, + containerIds: [], + }; + } + return { + safe: false, + reason: "managed bootstrap owner cleanup receipt does not match the requested sandbox", + deleteStatus: null, + sandboxPresent: null, + containerIds: [receipt.runtimeId], + }; + } + if (failure.nativeCleanupHandoff) { + return { + safe: false, + reason: + "managed bootstrap owner cleanup is required for the exact sandbox and runtime identities", + deleteStatus: null, + sandboxPresent: null, + containerIds: [failure.nativeCleanupHandoff.runtimeId], + }; + } + return cleanupNativeGpuAttemptForFallback(sandboxName, deps); +} + export type SandboxGpuCreatePlanDeps = { runAttempt(route: SelectedDockerGpuRoute): Promise>; captureNativeFailure?(failure: SandboxGpuCreateAttemptFailure): void; - cleanupNativeFailure(): NativeGpuFallbackCleanupResult | Promise; + cleanupNativeFailure( + failure: SandboxGpuCreateAttemptFailure, + ): NativeGpuFallbackCleanupResult | Promise; /** Validate and render the retry without mutating host or process state. */ prepareCompatibilityAttempt(failure: SandboxGpuCreateAttemptFailure): void | Promise; /** Apply compatibility side effects only after native cleanup is proven safe. */ @@ -267,7 +316,7 @@ export async function executeSandboxGpuCreatePlan( preparationRefused: error instanceof Error ? error.message : String(error), }; } - const cleanup = await deps.cleanupNativeFailure(); + const cleanup = await deps.cleanupNativeFailure(first); if (!cleanup.safe) { return { ...first, diff --git a/src/lib/onboard/sandbox-gpu-create-flow.ts b/src/lib/onboard/sandbox-gpu-create-flow.ts index 17b7e2ff552..1a418758459 100644 --- a/src/lib/onboard/sandbox-gpu-create-flow.ts +++ b/src/lib/onboard/sandbox-gpu-create-flow.ts @@ -35,11 +35,13 @@ import { } from "./experimental/portable-agent-lifecycle"; import { isPortableExperimentalProfile } from "./experimental/portable-profile"; import { + createManagedBootstrapIdentity, type ManagedBootstrapAdapter, type ManagedBootstrapAgentIdentity, type ManagedBootstrapAuthorityStore, type ManagedBootstrapImageIdentity, ManagedBootstrapRecoveryBlockedError, + renderManagedBootstrapHeldCommand, } from "./managed-bootstrap/adapter"; import type { ManagedBootstrapRuntimePatch } from "./managed-bootstrap/runtime-create"; import { assertPortableManagedBootstrapNotSelected } from "./managed-workload/onboard-orchestration"; @@ -51,6 +53,7 @@ import type { } from "./runtime-provider/contract"; import * as sandboxGpuCreateAttempt from "./sandbox-gpu-create-attempt"; import { createSandboxGpuCreateAttemptRunner } from "./sandbox-gpu-create-run-attempt"; +import { managedBootstrapCreateArgs } from "./sandbox-create-launch"; import type { SandboxGpuConfig } from "./sandbox-gpu-mode"; import { createDirectSandboxGpuVerifier, @@ -344,27 +347,49 @@ export async function runSandboxGpuCreateFlow( ); if (diagnostics) console.error(` Native GPU diagnostics saved: ${diagnostics.dir}`); }, - cleanupNativeFailure: () => - sandboxGpuCreateAttempt.cleanupNativeGpuAttemptForFallback(input.sandboxName, { - runOpenshell: deps.runOpenshell, - sleep: deps.sleep, - }), + cleanupNativeFailure: (failure) => { + return sandboxGpuCreateAttempt.cleanupNativeGpuFailureForFallback( + input.sandboxName, + failure, + { + runOpenshell: deps.runOpenshell, + sleep: deps.sleep, + }, + ); + }, prepareCompatibilityAttempt: async () => { if (!input.compatibilityPolicyPath) { throw new Error("Compatibility retry policy was not materialized."); } const nativeRuntimeSnapshot = attemptRunner.state.nativeRuntimeSnapshot; if (attemptRunner.managedRouting) { + const managedBootstrap = input.managedBootstrap; + if (!managedBootstrap) { + throw new Error("Managed compatibility routing is missing bootstrap authority."); + } + const bootstrapIdentity = createManagedBootstrapIdentity(); + const heldWorkloadArgv = [ + ...renderManagedBootstrapHeldCommand( + managedBootstrap.request, + bootstrapIdentity, + managedBootstrap.intendedWorkloadArgv, + ), + ]; const prepared = attemptRunner.managedRouting.prepareCompatibilityLaunch({ - createArgs: input.prebuild.createArgs, + createArgs: managedBootstrapCreateArgs( + input.prebuild.createArgs, + bootstrapIdentity, + ), currentRegistryImageRef: registryImageRef, prebuildImageId: input.prebuild.imageId, allowUnbuiltSource: attemptRunner.state.allowUnbuiltCompatibilitySource, compatibilityPolicyPath: input.compatibilityPolicyPath, - startupCommand: input.sandboxStartupCommand, + startupCommand: heldWorkloadArgv, runtimeSnapshot: nativeRuntimeSnapshot, }); attemptRunner.state.compatibilityArgv = [...prepared.createArgv]; + attemptRunner.state.compatibilityBootstrapIdentity = bootstrapIdentity; + attemptRunner.state.compatibilityHeldWorkloadArgv = heldWorkloadArgv; registryImageRef = prepared.registryImageRef; } else { const prebuildImageId = input.prebuild.imageId; @@ -437,7 +462,9 @@ export async function runSandboxGpuCreateFlow( ); } console.error( - hermesPortableLifecycle + gpuCreateOutcome.nativeCleanupHandoff + ? ` Managed bootstrap retained exact owner-cleanup authority for sandbox '${input.sandboxName}'. Do not delete a runtime by mutable sandbox name; preserve it for identity-bound recovery.` + : hermesPortableLifecycle ? ` Hermes portable sandbox '${input.sandboxName}' did not complete receipt-owned creation. Preserve its lifecycle receipt and resume onboarding after correcting the reported failure.` : ` Manual cleanup: openshell sandbox delete "${input.sandboxName}"`, ); diff --git a/src/lib/onboard/sandbox-gpu-create-run-attempt.ts b/src/lib/onboard/sandbox-gpu-create-run-attempt.ts index 68ae7984769..fcfb7dd8cbe 100644 --- a/src/lib/onboard/sandbox-gpu-create-run-attempt.ts +++ b/src/lib/onboard/sandbox-gpu-create-run-attempt.ts @@ -20,6 +20,9 @@ import { createDockerGpuSandboxCreatePatch } from "./docker-gpu-sandbox-create"; import { installPortableDemoSandboxLifecycle } from "./experimental/portable-demo-lifecycle"; import { enforceManagedBootstrapRecoveryForSandbox } from "./managed-bootstrap/adapter"; import type { + ManagedBootstrapNativeGpuFallbackOwnerCleanupHandoff, + ManagedBootstrapNativeGpuFallbackOwnerCleanupReceipt, + ManagedBootstrapRuntimeCreateLifecycle, ManagedBootstrapRuntimePatch, ManagedBootstrapRuntimeSnapshot, } from "./managed-bootstrap/runtime-create"; @@ -44,6 +47,8 @@ type NativeRuntimeSnapshot = ManagedBootstrapRuntimeSnapshot; export type SandboxGpuCreateAttemptState = { firstCreateOutput: string; compatibilityArgv: string[] | null; + compatibilityBootstrapIdentity: string | null; + compatibilityHeldWorkloadArgv: string[] | null; allowUnbuiltCompatibilitySource: boolean; nativeRuntimeSnapshot: NativeRuntimeSnapshot | null; portableLifecycleGeneration: string | null; @@ -99,6 +104,31 @@ function createPortableRuntimePatch( }; } +type NativeFallbackCleanupEvidence = Readonly<{ + nativeCleanupHandoff?: ManagedBootstrapNativeGpuFallbackOwnerCleanupHandoff; + nativeCleanupReceipt?: ManagedBootstrapNativeGpuFallbackOwnerCleanupReceipt; +}>; + +async function rollbackNativeGpuFailureForFallback( + managedLifecycle: ManagedBootstrapRuntimeCreateLifecycle | null, + runtimePatch: ManagedBootstrapRuntimePatch, +): Promise { + if (!managedLifecycle) { + await runtimePatch.rollbackManagedStartupAfterCreateFailure(); + return {}; + } + const rollback = await runtimePatch.rollbackManagedStartupAfterCreateFailure({ + ownerCleanupHandoff: "native-gpu-fallback-after-absent-attachment", + }); + if (rollback?.kind !== "openshell-owner-cleanup-required") return {}; + const ownerCleanup = managedLifecycle.completeNativeGpuFallbackOwnerCleanup + ? await managedLifecycle.completeNativeGpuFallbackOwnerCleanup(rollback) + : rollback; + return ownerCleanup.kind === "openshell-owner-cleanup-completed" + ? { nativeCleanupReceipt: ownerCleanup } + : { nativeCleanupHandoff: ownerCleanup }; +} + function normalizedOpenShellCommandOutput(result: OpenShellCommandResult): string { return `${String(result.stderr ?? "")}\n${String(result.stdout ?? "")}` .replace(ANSI_RE, "") @@ -220,6 +250,8 @@ export function createSandboxGpuCreateAttemptRunner( const state: SandboxGpuCreateAttemptState = { firstCreateOutput: "", compatibilityArgv: null, + compatibilityBootstrapIdentity: null, + compatibilityHeldWorkloadArgv: null, allowUnbuiltCompatibilitySource: false, nativeRuntimeSnapshot: null, portableLifecycleGeneration: null, @@ -253,18 +285,22 @@ export function createSandboxGpuCreateAttemptRunner( const hasRequiredUlimits = (input.requiredUlimits?.length ?? 0) > 0; const managedBootstrap = input.managedBootstrap ?? null; const attemptArgv = state.compatibilityArgv ?? input.createArgv; + const attemptBootstrapIdentity = + state.compatibilityBootstrapIdentity ?? managedBootstrap?.bootstrapIdentity ?? null; + const attemptHeldWorkloadArgv = + state.compatibilityHeldWorkloadArgv ?? input.sandboxStartupCommand; const managedLifecycle = managedBootstrap ? managedBootstrap.runtimeProvider.bootstrap.createLifecycle({ providerId: managedBootstrap.runtimeProvider.identity.id, stateRoot: managedBootstrap.stateRoot, - bootstrapIdentity: managedBootstrap.bootstrapIdentity, + bootstrapIdentity: attemptBootstrapIdentity ?? managedBootstrap.bootstrapIdentity, request: managedBootstrap.request, image: managedBootstrap.image, agentIdentity: managedBootstrap.agentIdentity, intendedWorkloadArgv: managedBootstrap.intendedWorkloadArgv, expectedSupervisorArgv: managedBootstrap.expectedSupervisorArgv, launchArgv: attemptArgv, - heldWorkloadArgv: input.sandboxStartupCommand, + heldWorkloadArgv: attemptHeldWorkloadArgv, authorityStore: managedBootstrap.authorityStore, ...(deps.createManagedBootstrapAdapter ? { adapterOverride: deps.createManagedBootstrapAdapter(managedBootstrap.stateRoot) } @@ -347,7 +383,7 @@ export function createSandboxGpuCreateAttemptRunner( return isSandboxReady(list, input.sandboxName); }, onPoll: () => { - if (!deferRestartSafeCutover) runtimePatch.maybeApplyDuringCreate(); + if (!deferRestartSafeCutover) void runtimePatch.maybeApplyDuringCreate(); }, readyCheckOutputPatterns: getReadyCheckOutputPatternsForAgent({ isTerminalAgent: input.terminalAgent, @@ -369,9 +405,9 @@ export function createSandboxGpuCreateAttemptRunner( createResult = await managedLifecycle.runCreate( async ({ heldWorkloadArgv, bootstrapIdentity }) => { if ( - bootstrapIdentity !== managedBootstrap.bootstrapIdentity || - heldWorkloadArgv.length !== input.sandboxStartupCommand.length || - heldWorkloadArgv.some((value, index) => value !== input.sandboxStartupCommand[index]) + bootstrapIdentity !== attemptBootstrapIdentity || + heldWorkloadArgv.length !== attemptHeldWorkloadArgv.length || + heldWorkloadArgv.some((value, index) => value !== attemptHeldWorkloadArgv[index]) ) { throw new Error( "Managed bootstrap launch does not match the rendered identity-bound hold.", @@ -663,7 +699,10 @@ export function createSandboxGpuCreateAttemptRunner( const snapshot = inspectNativeRuntime(); if (snapshot?.nativeGpuAttachmentState === "absent") { state.nativeRuntimeSnapshot = snapshot; - await runtimePatch.rollbackManagedStartupAfterCreateFailure(); + const nativeCleanup = await rollbackNativeGpuFailureForFallback( + managedLifecycle, + runtimePatch, + ); return { ok: false, route, @@ -672,6 +711,7 @@ export function createSandboxGpuCreateAttemptRunner( "Native OpenShell GPU proof failed and the host confirms no GPU attachment.", ), fallbackEligible: true, + ...nativeCleanup, } as const; } } diff --git a/src/lib/onboard/sandbox-gpu-fallback-orchestration.test.ts b/src/lib/onboard/sandbox-gpu-fallback-orchestration.test.ts index 8c165ef4bd0..692818fef72 100644 --- a/src/lib/onboard/sandbox-gpu-fallback-orchestration.test.ts +++ b/src/lib/onboard/sandbox-gpu-fallback-orchestration.test.ts @@ -8,6 +8,7 @@ import { type SelectedDockerGpuRoute, } from "./docker-gpu-route"; import { + cleanupNativeGpuFailureForFallback, executeSandboxGpuCreatePlan, type NativeGpuFallbackCleanupResult, type SandboxGpuCreateAttemptFailure, @@ -140,6 +141,41 @@ describe("executeSandboxGpuCreatePlan", () => { }); }); + it("uses an exact Hermes owner-cleanup receipt for one compatibility retry (#9935)", async () => { + const ownerCleanup = vi.fn(); + const native = { + ...nativeFailure("gpu-proof"), + nativeCleanupReceipt: { + kind: "openshell-owner-cleanup-completed" as const, + sandboxName: "alpha", + sandboxId: "sandbox-alpha", + runtimeId: "runtime-alpha", + }, + }; + const runAttempt = vi.fn(async (route: SelectedDockerGpuRoute) => + route === "native" + ? native + : { ok: true as const, route, value: "hermes-compatibility-ready" }, + ); + + await expect( + execute( + planDeps(runAttempt, { + cleanupNativeFailure: (failure) => + cleanupNativeGpuFailureForFallback("alpha", failure, { + runOpenshell: ownerCleanup, + }), + }), + ), + ).resolves.toEqual({ + ok: true, + route: "compatibility", + value: "hermes-compatibility-ready", + }); + expect(attemptedRoutes(runAttempt)).toEqual(["native", "compatibility"]); + expect(ownerCleanup).not.toHaveBeenCalled(); + }); + it("prepares and renders the built image before the single compatibility retry", async () => { const imageRef = `sha256:${"a".repeat(64)}`; let compatibilityArgs: string[] | null = null; diff --git a/src/lib/onboard/sandbox-workload-preparation.test.ts b/src/lib/onboard/sandbox-workload-preparation.test.ts index 8b4e420cbce..232ce20ac0e 100644 --- a/src/lib/onboard/sandbox-workload-preparation.test.ts +++ b/src/lib/onboard/sandbox-workload-preparation.test.ts @@ -201,6 +201,28 @@ describe("sandbox workload preparation", () => { } }); + it("uses an exact-revision E2E catalog when local git describe labels differ", async () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-catalog-")); + const catalogPath = path.join(fixtureRoot, "catalog.json"); + fs.writeFileSync(catalogPath, JSON.stringify(CATALOG), { mode: 0o600 }); + try { + const prepared = await prepareSandboxWorkloadSource({ + ...input("openclaw"), + version: "0.1.0", + catalogPath, + expectedCatalogRevision: REVISION, + }); + + expect(prepared.release).toBe(RELEASE); + expect(prepared.source).toMatchObject({ + kind: "managed-image", + contract: { source: { release: RELEASE, revision: REVISION } }, + }); + } finally { + fs.rmSync(fixtureRoot, { force: true, recursive: true }); + } + }); + 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/types.ts b/src/lib/onboard/types.ts index 42807587a73..ac0e20259af 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -80,6 +80,8 @@ export interface SandboxCreateIntent { readonly targetGeneration: string; readonly targetIntentFingerprint: string; }; + /** Internal outer-rebuild authority for carrying managed MCP state through replacement. */ + readonly recreateJournalTargetIntentFingerprint?: string; /** Validated non-secret Hermes environment assignments carried by a rebuild. */ readonly rebuildPreservedEnv?: readonly import("../state/preserved-env").PreservedEnvFile[]; /** Built-in policy presets owned by the outer authoritative rebuild lifecycle. */ diff --git a/src/lib/onboard/workload/preparation.ts b/src/lib/onboard/workload/preparation.ts index c35dfc4f9e7..69ca7b108fd 100644 --- a/src/lib/onboard/workload/preparation.ts +++ b/src/lib/onboard/workload/preparation.ts @@ -184,8 +184,10 @@ function requireCompleteManagedImageCatalog( catalog: ManagedImageContractCatalog, expectedRelease: string, expectedPlatform: ManagedImagePlatform, -): string { + expectedRevision: string | null, +): { readonly release: string; readonly revision: string } { let cohortRevision: string | null = null; + let cohortRelease: string | null = null; let publicationCohort: string | null = null; for (const agent of SHIPPED_MANAGED_IMAGE_AGENTS) { const candidate = catalog[agent]; @@ -196,11 +198,17 @@ function requireCompleteManagedImageCatalog( } try { const contract = parseManagedImageContractV1(candidate, agent, expectedPlatform); - if (contract.source.release !== expectedRelease) { + if (expectedRevision === null && contract.source.release !== expectedRelease) { throw new SandboxWorkloadPreparationError( `managed image catalog contract for '${agent}' belongs to '${contract.source.release}', not '${expectedRelease}'`, ); } + cohortRelease ??= contract.source.release; + if (contract.source.release !== cohortRelease) { + throw new SandboxWorkloadPreparationError( + "managed image catalog does not identify one all-agent release", + ); + } cohortRevision ??= contract.source.revision; if (contract.source.revision !== cohortRevision) { throw new SandboxWorkloadPreparationError( @@ -221,7 +229,12 @@ function requireCompleteManagedImageCatalog( ); } } - return cohortRevision!; + if (expectedRevision !== null && cohortRevision !== expectedRevision) { + throw new SandboxWorkloadPreparationError( + "managed image catalog source revision does not match the live E2E candidate revision", + ); + } + return { release: cohortRelease!, revision: cohortRevision! }; } function requireCandidateManagedImageCatalog( @@ -352,12 +365,13 @@ export async function prepareSandboxWorkloadSource( acceptedCandidateContract, ); } else { - const catalogRevision = requireCompleteManagedImageCatalog(catalog, release, platform); - if (input.expectedCatalogRevision && catalogRevision !== input.expectedCatalogRevision) { - throw new SandboxWorkloadPreparationError( - "managed image catalog source revision does not match the live E2E candidate revision", - ); - } + const catalogIdentity = requireCompleteManagedImageCatalog( + catalog, + release, + platform, + input.expectedCatalogRevision ?? null, + ); + release = catalogIdentity.release; } return { diff --git a/src/lib/runner.ts b/src/lib/runner.ts index 41bd00db762..c259b70e710 100644 --- a/src/lib/runner.ts +++ b/src/lib/runner.ts @@ -10,6 +10,7 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import { redirectInheritedChildStdoutToStderr } from "./cli/stdout-guard"; +import { REPOSITORY_ROOT } from "./core/repository-root"; import { shellQuote } from "./core/shell-quote"; import { detectDockerHost } from "./platform"; import { @@ -21,7 +22,7 @@ import { import { redact, redactError, redactFull, writeRedactedResult } from "./security/redact"; import { buildSubprocessEnv } from "./subprocess-env"; -const ROOT = path.resolve(__dirname, "..", ".."); +const ROOT = REPOSITORY_ROOT; const SCRIPTS = path.join(ROOT, "scripts"); type RunnerOptions = SpawnSyncOptions & { diff --git a/src/lib/sandbox-base-image/types.ts b/src/lib/sandbox-base-image/types.ts index 4f81f6faa8d..cf5ab131ce2 100644 --- a/src/lib/sandbox-base-image/types.ts +++ b/src/lib/sandbox-base-image/types.ts @@ -27,6 +27,7 @@ export type SandboxBaseImageResolutionMetadata = { ref: string; digest: string | null; source: SandboxBaseImageResolutionSource; + sourceRevision?: string; pinnedRemoteRef?: string; imageId: string; os: string; diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index c5438df0175..83d7964270a 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -1343,7 +1343,9 @@ function verifyHermesProviderMutablePosture(sandboxName: string, target: AgentCo "%a %U:%G", target.configDir, ]).split(" "); - if (mode !== "3770") issues.push(`${target.configDir} mode=${mode} (expected 3770)`); + if (mode !== "700" && mode !== "3770") { + issues.push(`${target.configDir} mode=${mode} (expected 700 or 3770)`); + } if (owner !== "sandbox:sandbox") { issues.push(`${target.configDir} owner=${owner} (expected sandbox:sandbox)`); } @@ -5283,13 +5285,22 @@ function shieldsDownWithoutHostLock( if (transition && timerAuthority) { assertFreshShieldsDownAuthority(sandboxName, timerAuthority, transition, "preparing"); } - unlockAgentConfig( - sandboxName, - target, - initialMode === "locked", - opts.allowLegacyHermesProtocol === true, - protocol, - ); + if ( + target.agentName === "hermes" && + protocol === "provider-state-mutation-v2" && + initialMode === "mutable_default" && + !hasActiveRuntimeProviderStateMutation(sandboxName) + ) { + verifyHermesProviderMutablePosture(sandboxName, target); + } else { + unlockAgentConfig( + sandboxName, + target, + initialMode === "locked", + opts.allowLegacyHermesProtocol === true, + protocol, + ); + } if (target.agentName === "hermes") { console.log(" Confirming Hermes inference route after policy transition..."); const convergence = waitForHermesInferenceRouteConvergence(sandboxName, { run }); diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts index 47bf53ec331..aa3b91bb5b6 100644 --- a/src/lib/shields/permissive-runtime.ts +++ b/src/lib/shields/permissive-runtime.ts @@ -106,6 +106,11 @@ export function buildRuntimePermissivePolicy( const liveRw = readStringList(live, "read_write"); const liveRo = readStringList(live, "read_only"); const managedMcpPolicies = deps.managedMcpPolicies ?? []; + const discordProviderName = deps.sandboxName + ? `${deps.sandboxName}-discord-bridge` + : null; + const preserveDiscordBinding = + discordProviderName !== null && policyUsesCredentialProvider(live, discordProviderName); // No live startup-sealed or filesystem state to carry forward — keep the // static path so the caller's apply path is unchanged unless exact managed @@ -136,7 +141,7 @@ export function buildRuntimePermissivePolicy( } return basePermissivePath; } - if (deps.sandboxName !== undefined) { + if (deps.sandboxName !== undefined && preserveDiscordBinding) { const materialized = materializeMessagingPolicySandboxName(baseYaml, deps.sandboxName); if (materialized === null) { throw new Error("Cannot materialize the Shields-down credential provider binding"); @@ -153,6 +158,12 @@ export function buildRuntimePermissivePolicy( } return basePermissivePath; } + if (deps.sandboxName !== undefined && !preserveDiscordBinding) { + const networkPolicies = base.network_policies; + if (networkPolicies && typeof networkPolicies === "object" && !Array.isArray(networkPolicies)) { + delete (networkPolicies as Record).discord; + } + } const fsPolicy = base.filesystem_policy && typeof base.filesystem_policy === "object" ? (base.filesystem_policy as Record) @@ -343,6 +354,30 @@ function safeYamlObject(text: string): Record | null { return null; } +function policyUsesCredentialProvider( + policy: Record | null, + providerName: string, +): boolean { + const networkPolicies = policy?.network_policies; + if (!networkPolicies || typeof networkPolicies !== "object" || Array.isArray(networkPolicies)) { + return false; + } + for (const networkPolicy of Object.values(networkPolicies)) { + if (!networkPolicy || typeof networkPolicy !== "object" || Array.isArray(networkPolicy)) { + continue; + } + const endpoints = (networkPolicy as Record).endpoints; + if (!Array.isArray(endpoints)) continue; + for (const endpoint of endpoints) { + if (!endpoint || typeof endpoint !== "object" || Array.isArray(endpoint)) continue; + const binding = (endpoint as Record).credential_binding; + if (!binding || typeof binding !== "object" || Array.isArray(binding)) continue; + if ((binding as Record).provider === providerName) return true; + } + } + return false; +} + function readStringList( root: Record | null, key: "read_only" | "read_write", diff --git a/src/lib/state/mcp-lifecycle-lock-identity.test.ts b/src/lib/state/mcp-lifecycle-lock-identity.test.ts index 78dc34da5f1..deab2fbc862 100644 --- a/src/lib/state/mcp-lifecycle-lock-identity.test.ts +++ b/src/lib/state/mcp-lifecycle-lock-identity.test.ts @@ -451,14 +451,18 @@ describe("MCP lifecycle lock identity properties", () => { // fc draws ageMs and graceMs independently, so ageMs === graceMs is // almost never sampled. This loop tests the >= comparison at the exact ages. - expect([graceMs - 1, graceMs, graceMs + 1, ageMs].every((boundaryAgeMs) => + expect( + [graceMs - 1, graceMs, graceMs + 1, ageMs].every( + (boundaryAgeMs) => classifyMcpLifecycleLock( observation(lockOwner, 0), SANDBOX_NAME, boundaryAgeMs, graceMs, localProbes, - ) === (boundaryAgeMs >= graceMs ? "stale" : "wait"))).toBe(true); + ) === (boundaryAgeMs >= graceMs ? "stale" : "wait"), + ), + ).toBe(true); }, ), SEEDED_PROPERTY_PARAMETERS, diff --git a/src/lib/state/paths.ts b/src/lib/state/paths.ts index 72b9e0b2e7e..a918b2ecbe4 100644 --- a/src/lib/state/paths.ts +++ b/src/lib/state/paths.ts @@ -5,9 +5,10 @@ import os from "node:os"; import path from "node:path"; import { GATEWAY_PORT } from "../core/ports"; +import { REPOSITORY_ROOT } from "../core/repository-root"; import { nemoclawStateRoot } from "./state-root"; -export const ROOT = path.resolve(__dirname, "..", "..", ".."); +export const ROOT = REPOSITORY_ROOT; export const SCRIPTS = path.join(ROOT, "scripts"); export function resolveNemoclawHomeDir(homeDir: string = process.env.HOME ?? os.homedir()): string { diff --git a/test/advisor-repo-read-only-tools.test.ts b/test/advisor-repo-read-only-tools.test.ts index 96b34f4e0db..78b61c7b1a9 100644 --- a/test/advisor-repo-read-only-tools.test.ts +++ b/test/advisor-repo-read-only-tools.test.ts @@ -11,9 +11,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { canonicalRepoReadPath, createRepoConfinedReadOnlyTools, + MAX_ADVISOR_TOOL_RESULT_JSON_BYTES, } from "../tools/advisors/repo-read-only-tools.mts"; const tempDirs: string[] = []; +const PI_SESSION_READ_LINE_LIMIT_BYTES = 50 * 1024; let workspace: string; let outside: string; let tools: Map; @@ -178,6 +180,84 @@ describe("repo-confined advisor read-only tools", () => { ]); }); + it("keeps escaped read results within the specialist session line limit (#9949)", async () => { + const lineCount = 40; + const escapedLine = `const value = ${JSON.stringify('\\"'.repeat(96))};`; + fs.writeFileSync( + path.join(workspace, "escaped-read.txt"), + `${Array.from({ length: lineCount }, () => escapedLine).join("\n")}\n`, + "utf8", + ); + const observations: Parameters< + NonNullable[1]> + >[0][] = []; + tools = new Map( + createRepoConfinedReadOnlyTools(workspace, (observation) => observations.push(observation)).map( + (tool) => [tool.name, tool], + ), + ); + + const first = await execute("read", { path: "escaped-read.txt", offset: 1 }); + expect(Buffer.byteLength(JSON.stringify(first), "utf8")).toBeLessThanOrEqual( + MAX_ADVISOR_TOOL_RESULT_JSON_BYTES, + ); + expect( + Buffer.byteLength( + JSON.stringify({ + type: "message", + id: "result-1", + parentId: "call-1", + timestamp: "2026-01-01T00:00:00.000Z", + message: { + role: "toolResult", + toolCallId: "call-1", + toolName: "read", + content: first.content, + details: first.details, + isError: false, + }, + }), + "utf8", + ), + ).toBeLessThanOrEqual(PI_SESSION_READ_LINE_LIMIT_BYTES); + const firstTruncation = ( + first.details as { truncation?: { truncated: boolean; outputLines: number } } | undefined + )?.truncation; + expect(firstTruncation?.truncated).toBe(true); + expect(firstTruncation?.outputLines).toBeGreaterThan(0); + const nextOffset = 1 + (firstTruncation?.outputLines ?? 0); + expect((first.content[0] as { text: string }).text).toContain( + `Use offset=${nextOffset} to continue`, + ); + + const second = await execute("read", { path: "escaped-read.txt", offset: nextOffset }); + expect(Buffer.byteLength(JSON.stringify(second), "utf8")).toBeLessThanOrEqual( + MAX_ADVISOR_TOOL_RESULT_JSON_BYTES, + ); + expect( + Buffer.byteLength( + JSON.stringify({ + type: "message", + id: "result-2", + parentId: "call-2", + timestamp: "2026-01-01T00:00:00.000Z", + message: { + role: "toolResult", + toolCallId: "call-2", + toolName: "read", + content: second.content, + details: second.details, + isError: false, + }, + }), + "utf8", + ), + ).toBeLessThanOrEqual(PI_SESSION_READ_LINE_LIMIT_BYTES); + + expect(observations.at(-1)?.reachesEnd).toBe(true); + expect(observations.at(-1)?.endOffset).toBeNull(); + }); + it("uses one canonical path for configured and observed reads", async () => { fs.writeFileSync(path.join(workspace, "required.txt"), "required\n", "utf8"); const observations: Parameters< diff --git a/test/e2e/fixtures/availability-env.ts b/test/e2e/fixtures/availability-env.ts index e2ee67dc498..29736629163 100644 --- a/test/e2e/fixtures/availability-env.ts +++ b/test/e2e/fixtures/availability-env.ts @@ -10,10 +10,14 @@ const AVAILABILITY_PROBE_EXTRA_ENV_KEYS = [ "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "DOCKER_API_VERSION", + "GITHUB_WORKSPACE", "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR", + "NEMOCLAW_E2E_EXPECTED_SHA", + "NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG", "NEMOCLAW_OLLAMA_PULL_TIMEOUT", "NEMOCLAW_EXPERIMENTAL_PROFILE", + "NEMOCLAW_RUN_LIVE_E2E", "NEMOCLAW_TRACE_DIR", ]; @@ -22,7 +26,8 @@ export function buildAvailabilityProbeEnv( ): 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 on top of the shared boundary. + // workflow-owned local-model pull budget and exact PR catalog authority on + // top of the shared boundary. return buildChildEnv(base, { additionalAllowedEnv: AVAILABILITY_PROBE_EXTRA_ENV_KEYS, fixtureOverlay: {}, diff --git a/test/e2e/fixtures/corporate-ca.ts b/test/e2e/fixtures/corporate-ca.ts index 87d1dc52fd0..02a12cf7c87 100644 --- a/test/e2e/fixtures/corporate-ca.ts +++ b/test/e2e/fixtures/corporate-ca.ts @@ -5,9 +5,17 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { isObjectRecord } from "../../../src/lib/core/json-types.ts"; +import { GATEWAY_PORT } from "../../../src/lib/core/ports.ts"; +import { readManagedWorkloadAuthority } from "../../../src/lib/onboard/workload/authority.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"; import { trustedSandboxShellScript, type TrustedSandboxShellScript } from "./clients/sandbox.ts"; export type CorporateCaFixtureMode = "explicit" | "requests" | "host-anchor"; +export type CorporateCaWorkloadKind = "legacy-dockerfile" | "managed-image"; export interface CorporateCaFixture { dir: string; @@ -61,7 +69,10 @@ const CORPORATE_CA_ENV_BY_MODE: Record< "host-anchor": (_file, dir) => ({ NEMOCLAW_CORPORATE_CA_ANCHOR_DIRS: dir }), }; -const CORPORATE_CA_MERGE_PROBE = trustedSandboxShellScript(` +function buildCorporateCaMergeProbe( + workloadKind: CorporateCaWorkloadKind, +): TrustedSandboxShellScript { + return trustedSandboxShellScript(` set -eu probe_fail() { printf 'CORPORATE_CA_PROBE_FAIL:%s\\n' "$1" >&2 @@ -79,16 +90,18 @@ expect_export() { } corp='/usr/local/share/nemoclaw/corporate-ca.pem' -managed_completion='/run/nemoclaw/managed-startup-complete.json' -if [ -e "$managed_completion" ] || [ -L "$managed_completion" ]; then - [ -f "$managed_completion" ] && [ ! -L "$managed_completion" ] || probe_fail invalid-managed-completion +workload_kind='${workloadKind}' +if [ "$workload_kind" = 'managed-image' ]; then bundle='/run/nemoclaw/managed-startup-ca-bundle.pem' runtime_env='/run/nemoclaw/managed-startup-runtime.env' + system_bundle='/etc/ssl/certs/ca-certificates.crt' expected_bundle_metadata='0:0:444' + expected_runtime_env_metadata='0:0:444' else bundle='/tmp/nemoclaw-ca-bundle.pem' runtime_env='/tmp/nemoclaw-proxy-env.sh' expected_bundle_metadata="$(id -u):$(id -g):444" + expected_runtime_env_metadata="$(id -u):$(id -g):444" fi [ -s "$corp" ] || probe_fail missing-corporate-ca @@ -96,20 +109,35 @@ fi [ -s "$runtime_env" ] || probe_fail missing-runtime-env [ ! -L "$bundle" ] || probe_fail symlinked-merged-bundle [ "$(stat -c '%u:%g:%a' "$bundle")" = "$expected_bundle_metadata" ] || probe_fail merged-bundle-owner-mode +[ ! -L "$runtime_env" ] || probe_fail symlinked-runtime-env +[ "$(stat -c '%u:%g:%a' "$runtime_env")" = "$expected_runtime_env_metadata" ] || probe_fail runtime-env-owner-mode grep -F '${CORPORATE_CA_CANARY_LINE}' "$corp" >/dev/null || probe_fail corporate-canary-missing grep -F '${CORPORATE_CA_CANARY_LINE}' "$bundle" >/dev/null || probe_fail bundle-canary-missing +if [ "$workload_kind" = 'managed-image' ]; then + grep -F '${CORPORATE_CA_CANARY_LINE}' "$system_bundle" >/dev/null || probe_fail system-bundle-canary-missing +fi set -- $(wc -c < "$corp") corp_bytes="$1" set -- $(wc -c < "$bundle") bundle_bytes="$1" [ "$bundle_bytes" -gt "$corp_bytes" ] || probe_fail bundle-did-not-preserve-base -for env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do - expect_export "$env_name" -done +if [ "$workload_kind" = 'managed-image' ]; then + grep -F "export _NEMOCLAW_CORPORATE_CA_MERGED='1'" "$runtime_env" >/dev/null || probe_fail managed-runtime-env-marker + for env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do + if grep -E "^(export|unset) $env_name(=|$)" "$runtime_env" >/dev/null; then + probe_fail "managed-runtime-env-$env_name" + fi + done +else + for env_name in SSL_CERT_FILE CURL_CA_BUNDLE REQUESTS_CA_BUNDLE GIT_SSL_CAINFO NODE_EXTRA_CA_CERTS; do + expect_export "$env_name" + done +fi printf 'corporate CA baked and merged into %s (%s > %s bytes)\\n' "$bundle" "$bundle_bytes" "$corp_bytes" `); +} export function createCorporateCaFixture( mode: CorporateCaFixtureMode, @@ -132,6 +160,28 @@ export function cleanupCorporateCaFixture(fixture: CorporateCaFixture): void { fs.rmSync(fixture.dir, { recursive: true, force: true }); } -export function corporateCaMergeProbeScript(): TrustedSandboxShellScript { - return CORPORATE_CA_MERGE_PROBE; +export function registeredCorporateCaWorkloadKind( + sandboxName: string, + home: string = os.homedir(), + gatewayPort: number = GATEWAY_PORT, +): CorporateCaWorkloadKind { + const registryPath = path.join(nemoclawStateRoot(home, gatewayPort), "sandboxes.json"); + const registry = readConfigFile(registryPath, { sandboxes: {} }); + const sandboxes = isObjectRecord(registry) ? registry.sandboxes : undefined; + const entry = parseSandboxRegistryEntries(sandboxes).find(([name]) => name === sandboxName)?.[1]; + if (!entry) { + throw new Error(`corporate CA probe sandbox '${sandboxName}' is missing from the registry`); + } + if (readManagedWorkloadAuthority(entry)) return "managed-image"; + const workload = cloneSandboxWorkloadReceipt(entry.workload); + if (workload?.kind === "legacy-dockerfile") return workload.kind; + throw new Error( + `corporate CA probe sandbox '${sandboxName}' has no supported registered workload authority`, + ); +} + +export function corporateCaMergeProbeScript( + workloadKind: CorporateCaWorkloadKind, +): TrustedSandboxShellScript { + return buildCorporateCaMergeProbe(workloadKind); } diff --git a/test/e2e/live/channels-add-remove-helpers.ts b/test/e2e/live/channels-add-remove-helpers.ts index 7f6d51b1a3b..becb2d108a3 100644 --- a/test/e2e/live/channels-add-remove-helpers.ts +++ b/test/e2e/live/channels-add-remove-helpers.ts @@ -6,13 +6,21 @@ // it does not expose credential-bearing OpenClaw configuration. export interface OpenClawTelegramState { + accountPresent: boolean; accountEnabled: boolean; channelEnabled: boolean; channelPresent: boolean; + credentialPresent: boolean; pluginEnabled: boolean; pluginPresent: boolean; } export function openClawHasConfiguredTelegram(state: OpenClawTelegramState): boolean { - return state.channelEnabled && state.pluginEnabled; + return ( + state.accountPresent || + state.accountEnabled || + state.channelEnabled || + state.credentialPresent || + state.pluginEnabled + ); } diff --git a/test/e2e/live/channels-add-remove.test.ts b/test/e2e/live/channels-add-remove.test.ts index 09c1320df76..f5d96394b10 100644 --- a/test/e2e/live/channels-add-remove.test.ts +++ b/test/e2e/live/channels-add-remove.test.ts @@ -304,7 +304,8 @@ async function readOpenClawTelegramState( "channel=channels.get('telegram', {})", "plugin=plugins.get('telegram', {})", "accounts=channel.get('accounts', {})", - "state={'channelPresent': 'telegram' in channels, 'pluginPresent': 'telegram' in plugins, 'channelEnabled': channel.get('enabled') is True, 'pluginEnabled': plugin.get('enabled') is True, 'accountEnabled': any(isinstance(account, dict) and account.get('enabled') is True for account in accounts.values())}", + "account_values=list(accounts.values()) if isinstance(accounts, dict) else []", + "state={'channelPresent': 'telegram' in channels, 'pluginPresent': 'telegram' in plugins, 'channelEnabled': channel.get('enabled') is True, 'pluginEnabled': plugin.get('enabled') is True, 'accountPresent': len(account_values) > 0, 'accountEnabled': any(isinstance(account, dict) and account.get('enabled') is True for account in account_values), 'credentialPresent': any(isinstance(account, dict) and ('botToken' in account or 'token' in account) for account in account_values)}", "print(json.dumps(state))", ].join("; "), ], @@ -474,7 +475,15 @@ test( "phase-2-openclaw-json-baseline", ); expect(openClawHasConfiguredTelegram(baselineTelegram)).toBe(false); - expect(baselineTelegram.accountEnabled).toBe(false); + expect(baselineTelegram).toMatchObject({ + accountEnabled: false, + accountPresent: false, + channelEnabled: false, + channelPresent: true, + credentialPresent: false, + pluginEnabled: false, + pluginPresent: true, + }); await expectPolicyPreset(host, "telegram", "not-applied", "phase-2-policy-list-baseline"); progress.phase("add Telegram and rebuild sandbox"); @@ -523,8 +532,12 @@ test( expect(openClawHasConfiguredTelegram(activeTelegram)).toBe(true); expect(activeTelegram).toMatchObject({ accountEnabled: true, + accountPresent: true, channelEnabled: true, + channelPresent: true, + credentialPresent: true, pluginEnabled: true, + pluginPresent: true, }); await expectProvider(host, "present", "phase-4-provider-get-after-add"); expectHostTelegramConfig("after add+rebuild"); @@ -576,8 +589,12 @@ test( expect(openClawHasConfiguredTelegram(removedTelegram)).toBe(false); expect(removedTelegram).toMatchObject({ accountEnabled: false, + accountPresent: false, channelEnabled: false, - channelPresent: false, + channelPresent: true, + credentialPresent: false, + pluginEnabled: false, + pluginPresent: true, }); await expectProvider(host, "absent", "phase-6-provider-get-after-remove"); await expectPolicyPreset(host, "telegram", "not-applied", "phase-6-policy-list-after-remove"); diff --git a/test/e2e/live/cloud-onboard.test.ts b/test/e2e/live/cloud-onboard.test.ts index b6c9f3f2460..d54fdf3a619 100644 --- a/test/e2e/live/cloud-onboard.test.ts +++ b/test/e2e/live/cloud-onboard.test.ts @@ -12,6 +12,7 @@ import { cleanupCorporateCaFixture, corporateCaMergeProbeScript, createCorporateCaFixture, + registeredCorporateCaWorkloadKind, } from "../fixtures/corporate-ca.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { requireHostedInferenceConfig } from "../fixtures/hosted-inference.ts"; @@ -21,15 +22,42 @@ import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-cloud-onboard"; const CHECKS_DIR = path.join(REPO_ROOT, "test/e2e/e2e-cloud-experimental/checks"); const LIVE_TIMEOUT_MS = 60 * 60_000; -const REASONING_MODEL_PROBE = String.raw` +const REASONING_PROPAGATION_PROBE = String.raw` const fs = require("node:fs"); const expectedModel = process.argv[1]; +const runtimeEnvironmentPath = "/run/nemoclaw/managed-startup-runtime.env"; +const runtimeEnvironmentStat = fs.lstatSync(runtimeEnvironmentPath); +if ( + !runtimeEnvironmentStat.isFile() || + runtimeEnvironmentStat.isSymbolicLink() || + runtimeEnvironmentStat.uid !== 0 || + runtimeEnvironmentStat.gid !== 0 || + (runtimeEnvironmentStat.mode & 0o777) !== 0o444 +) { + throw new Error("managed startup runtime environment is not a root-owned mode 0444 regular file"); +} +const runtimeReasoningLines = fs + .readFileSync(runtimeEnvironmentPath, "utf8") + .split(/\r?\n/u) + .filter((line) => line.startsWith("export NEMOCLAW_REASONING=")); +if (runtimeReasoningLines.length !== 1) { + throw new Error("managed startup runtime environment must export NEMOCLAW_REASONING exactly once"); +} +const runtimeReasoningMatch = /^export NEMOCLAW_REASONING='(true|false)'$/u.exec( + runtimeReasoningLines[0], +); +if (runtimeReasoningMatch === null) { + throw new Error("managed startup runtime environment has an invalid NEMOCLAW_REASONING export"); +} const config = JSON.parse(fs.readFileSync("/sandbox/.openclaw/openclaw.json", "utf8")); const models = config.models?.providers?.inference?.models ?? []; const model = models.find((entry) => entry?.id === expectedModel); -const evidence = { modelReasoning: model?.reasoning }; +const evidence = { + runtimeReasoning: runtimeReasoningMatch[1], + modelReasoning: model?.reasoning, +}; console.log(JSON.stringify(evidence)); -process.exit(evidence.modelReasoning === true ? 0 : 1); +process.exit(evidence.runtimeReasoning === "true" && evidence.modelReasoning === true ? 0 : 1); `; validateSandboxName(SANDBOX_NAME); @@ -136,7 +164,7 @@ test("cloud onboard: public installer creates healthy sandbox with security chec "successful onboard removes plaintext credentials.json", "sandbox appears healthy after cloud onboarding", "explicit corporate CA source is baked and merged with OpenShell trust inside the sandbox", - "validated compatible-endpoint reasoning reaches both the built image environment and OpenClaw model metadata", + "validated compatible-endpoint reasoning reaches the authenticated runtime handoff and OpenClaw model metadata", "installed CLI creates a non-empty diagnostics archive for the registered sandbox", "cloud split checks cover inference.local, security leak checks, and Landlock/read-only behavior", "cleanup verifies sandbox removal", @@ -241,33 +269,21 @@ test("cloud onboard: public installer creates healthy sandbox with security chec expect(list.exitCode, resultText(list)).toBe(0); expect(list.stdout).toContain(SANDBOX_NAME); - const corporateCaProbe = await sandbox.execShell(SANDBOX_NAME, corporateCaMergeProbeScript(), { - artifactName: "phase-2-corporate-ca-merge-probe", - env: testEnv(), - timeoutMs: 60_000, - }); - expect(corporateCaProbe.exitCode, resultText(corporateCaProbe)).toBe(0); - - progress.phase("verify compatible endpoint reasoning propagation"); - const imageEnvironment = await host.command( - "bash", - [ - "-lc", - `set -eu; container_id="$(docker ps --filter ${shellQuote( - `label=openshell.ai/sandbox-name=${SANDBOX_NAME}`, - )} --format '{{.ID}}' | head -n 1)"; test -n "$container_id"; reasoning="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container_id" | sed -n 's/^NEMOCLAW_REASONING=//p')"; case "$reasoning" in true|false) printf '%s\n' "$reasoning" ;; *) exit 1 ;; esac`, - ], + const corporateCaProbe = await sandbox.execShell( + SANDBOX_NAME, + corporateCaMergeProbeScript(registeredCorporateCaWorkloadKind(SANDBOX_NAME, testHome)), { - artifactName: "phase-2-compatible-endpoint-reasoning-image-environment", + artifactName: "phase-2-corporate-ca-merge-probe", env: testEnv(), timeoutMs: 60_000, }, ); - expect(imageEnvironment.exitCode, resultText(imageEnvironment)).toBe(0); - const imageReasoning = imageEnvironment.stdout.trim(); + expect(corporateCaProbe.exitCode, resultText(corporateCaProbe)).toBe(0); + + progress.phase("verify compatible endpoint reasoning propagation"); const reasoningProbe = await sandbox.exec( SANDBOX_NAME, - ["node", "-e", REASONING_MODEL_PROBE, hosted.model], + ["node", "-e", REASONING_PROPAGATION_PROBE, hosted.model], { artifactName: "phase-2-compatible-endpoint-reasoning", env: testEnv(), @@ -276,11 +292,11 @@ test("cloud onboard: public installer creates healthy sandbox with security chec ); expect(reasoningProbe.exitCode, resultText(reasoningProbe)).toBe(0); const reasoningEvidence = JSON.parse(reasoningProbe.stdout.trim()) as { + runtimeReasoning: string; modelReasoning: boolean; }; - const combinedReasoningEvidence = { imageReasoning, ...reasoningEvidence }; - expect(combinedReasoningEvidence).toEqual({ imageReasoning: "true", modelReasoning: true }); - await artifacts.writeJson("compatible-endpoint-reasoning.json", combinedReasoningEvidence); + expect(reasoningEvidence).toEqual({ runtimeReasoning: "true", modelReasoning: true }); + await artifacts.writeJson("compatible-endpoint-reasoning.json", reasoningEvidence); progress.phase("collect scoped diagnostics from onboarded sandbox"); const diagnosticsArchive = path.join(installCwd, "cloud-onboard-debug.tar.gz"); diff --git a/test/e2e/live/dashboard-connect-handoff.ts b/test/e2e/live/dashboard-connect-handoff.ts new file mode 100644 index 00000000000..e05dafd0cc1 --- /dev/null +++ b/test/e2e/live/dashboard-connect-handoff.ts @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChildProcess } from "node:child_process"; + +import type { ArtifactSink } from "../fixtures/artifacts.ts"; +import { + type ChildProcessProgress, + 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; +const CONNECT_STOP_GRACE_MS = 5_000; + +export interface DashboardConnectHandoffResult { + readonly exitCode: number | null; + readonly proof: "command-completed" | "forward-started"; + readonly signal: NodeJS.Signals | null; + readonly stderr: string; + readonly stdout: string; +} + +export interface DashboardConnectHandoffOptions { + readonly artifacts: ArtifactSink; + readonly command?: readonly [string, ...string[]]; + readonly env: NodeJS.ProcessEnv; + readonly progress: ChildProcessProgress; + readonly sandboxName: string; + readonly signal?: AbortSignal; + readonly stopGraceMs?: number; + readonly timeoutMs: number; + readonly dashboardPort: string; +} + +function signalChild(child: ChildProcess, signal: NodeJS.Signals): void { + try { + child.kill(signal); + } catch { + // The child may have exited between the proof callback and cleanup. + } +} + +function signalChildGroup(child: ChildProcess, signal: NodeJS.Signals): void { + try { + if (child.pid !== undefined) { + process.kill(-child.pid, signal); + return; + } + } catch { + // Fall back to the group leader when the process group is already gone. + } + signalChild(child, signal); +} + +function appendCaptured(current: string, chunk: string): string { + const next = current + chunk; + if (Buffer.byteLength(next, "utf8") > CONNECT_CAPTURE_LIMIT_BYTES) { + throw new Error("dashboard connect output exceeded the 1 MiB capture limit"); + } + return next; +} + +/** + * Observe ordinary interactive `connect` until it either finishes normally or + * proves that forward recovery completed. A proof stops only the connect group + * leader first: NemoClaw forwards SIGTERM to its attached OpenShell shell, + * while a correctly backgrounded dashboard forward has already detached its + * descriptors and remains available for the caller's independent health check. + */ +export async function runDashboardConnectUntilForwardHandoff( + options: DashboardConnectHandoffOptions, +): Promise { + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { + throw new RangeError("dashboard connect handoff timeout must be a positive finite value"); + } + const stopGraceMs = options.stopGraceMs ?? CONNECT_STOP_GRACE_MS; + if (!Number.isFinite(stopGraceMs) || stopGraceMs <= 0) { + throw new RangeError("dashboard connect stop grace must be a positive finite value"); + } + + const [command, ...args] = options.command ?? ["nemoclaw", options.sandboxName, "connect"]; + const child = spawnObservedChild(command, args, { + activityLabel: "command: dashboard-remote-bind-connect", + progress: options.progress, + spawn: { + cwd: REPO_ROOT, + detached: true, + env: resolveLiveE2eWorkloadSourceEnv({ ...options.env }), + stdio: ["ignore", "pipe", "pipe"], + }, + }); + + let stdout = ""; + let stderr = ""; + let forwardProof = false; + let proofStopRequested = false; + let deadlineExpired = false; + let aborted = false; + let cleanupEscalated = false; + let captureError: Error | null = null; + let forceKillTimer: NodeJS.Timeout | undefined; + + const scheduleForcedCleanup = (): void => { + if (forceKillTimer) return; + forceKillTimer = setTimeout(() => { + cleanupEscalated = true; + signalChildGroup(child, "SIGKILL"); + }, stopGraceMs); + }; + const terminateGroup = (): void => { + signalChildGroup(child, "SIGTERM"); + scheduleForcedCleanup(); + }; + const requestProofStop = (): void => { + if (proofStopRequested) return; + proofStopRequested = true; + signalChild(child, "SIGTERM"); + scheduleForcedCleanup(); + }; + const inspectProof = (): void => { + if (forwardProof || captureError) return; + forwardProof = dashboardRemoteBindConnectStarted( + { exitCode: null, stdout, stderr }, + options.sandboxName, + options.dashboardPort, + ); + if (forwardProof) requestProofStop(); + }; + const capture = (stream: "stdout" | "stderr", chunk: Buffer | string): void => { + if (captureError) return; + try { + if (stream === "stdout") stdout = appendCaptured(stdout, chunk.toString()); + else stderr = appendCaptured(stderr, chunk.toString()); + inspectProof(); + } catch (error) { + captureError = error instanceof Error ? error : new Error(String(error)); + terminateGroup(); + } + }; + child.stdout?.on("data", (chunk: Buffer | string) => capture("stdout", chunk)); + child.stderr?.on("data", (chunk: Buffer | string) => capture("stderr", chunk)); + + const deadline = setTimeout(() => { + deadlineExpired = true; + terminateGroup(); + }, options.timeoutMs); + const abort = (): void => { + aborted = true; + terminateGroup(); + }; + if (options.signal?.aborted) abort(); + else options.signal?.addEventListener("abort", abort, { once: true }); + + let spawnError: Error | null = null; + child.once("error", (error) => { + spawnError = error; + }); + const { exitCode, signal } = await new Promise<{ + exitCode: number | null; + signal: NodeJS.Signals | null; + }>((resolve) => { + child.once("close", (code, closeSignal) => resolve({ exitCode: code, signal: closeSignal })); + }); + clearTimeout(deadline); + if (forceKillTimer) clearTimeout(forceKillTimer); + options.signal?.removeEventListener("abort", abort); + + const artifactBase = "dashboard-connect-handoff"; + const artifactPaths = { + stdout: await options.artifacts.writeText(`${artifactBase}.stdout.txt`, stdout), + stderr: await options.artifacts.writeText(`${artifactBase}.stderr.txt`, stderr), + }; + await options.artifacts.writeJson(`${artifactBase}.result.json`, { + command: [command, ...args], + exitCode, + signal, + deadlineExpired, + cleanupEscalated, + forwardProof, + proofStopRequested, + stdout: artifactPaths.stdout, + stderr: artifactPaths.stderr, + }); + + if (spawnError) throw spawnError; + if (captureError) throw captureError; + if (aborted) throw new Error("dashboard connect handoff was cancelled"); + if (deadlineExpired) { + throw new Error("dashboard connect did not complete or prove forward handoff within budget"); + } + if (forwardProof) { + if (cleanupEscalated) { + throw new Error( + "dashboard connect retained captured descriptors after forward proof and required forced cleanup", + ); + } + return { exitCode, proof: "forward-started", signal, stderr, stdout }; + } + if (exitCode === 0) { + return { exitCode, proof: "command-completed", signal, stderr, stdout }; + } + throw new Error( + `dashboard connect exited before proving forward handoff (exit ${exitCode ?? "unknown"}${signal ? `, signal ${signal}` : ""})`, + ); +} diff --git a/test/e2e/live/dashboard-remote-bind-env.ts b/test/e2e/live/dashboard-remote-bind-env.ts index 623a8d47c18..40c52b8f780 100644 --- a/test/e2e/live/dashboard-remote-bind-env.ts +++ b/test/e2e/live/dashboard-remote-bind-env.ts @@ -46,3 +46,8 @@ export function dashboardRemoteBindConnectStarted( output.includes(`sandbox ${sandboxName}`)))) ); } + +export function dashboardForwardIsRunning(forwardLine: string): boolean { + const columns = stripAnsi(forwardLine).trim().split(/\s+/u); + return columns.length === 5 && columns[4] === "running"; +} diff --git a/test/e2e/live/dashboard-remote-bind.test.ts b/test/e2e/live/dashboard-remote-bind.test.ts index ac4c55c5627..c8651fdfdb3 100644 --- a/test/e2e/live/dashboard-remote-bind.test.ts +++ b/test/e2e/live/dashboard-remote-bind.test.ts @@ -9,9 +9,10 @@ import { sandboxAccessEnv, trustedSandboxShellScript } from "../fixtures/clients import { expect, test } from "../fixtures/e2e-test.ts"; import { requireHostedInferenceConfig } from "../fixtures/hosted-inference.ts"; import { CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; +import { runDashboardConnectUntilForwardHandoff } from "./dashboard-connect-handoff.ts"; import { buildDashboardRemoteBindEnv, - dashboardRemoteBindConnectStarted, + dashboardForwardIsRunning, } from "./dashboard-remote-bind-env.ts"; import { parseJsonFromText } from "./json-envelope.ts"; @@ -183,15 +184,19 @@ runDashboardRemoteBindTest( timeoutMs: 30_000, }); - const connect = await host.nemoclaw([sandboxName, "connect"], { - artifactName: "dashboard-remote-bind-connect", + const connect = await runDashboardConnectUntilForwardHandoff({ + artifacts, + dashboardPort, env: testEnv(), + progress, + sandboxName, + signal: cleanup.currentSignal(), timeoutMs: 120_000, }); expect( - dashboardRemoteBindConnectStarted(connect, sandboxName, dashboardPort), - `nemoclaw connect did not complete or print background-forward proof\nstdout:\n${connect.stdout}\nstderr:\n${connect.stderr}`, - ).toBe(true); + connect.proof, + "nemoclaw connect did not complete or print background-forward proof; see the dashboard-connect-handoff.stdout.txt and dashboard-connect-handoff.stderr.txt artifacts", + ).toBe("forward-started"); progress.phase("verify all-interface dashboard forward"); const forwardList = await sandbox.openshell(["forward", "list"], { @@ -207,6 +212,10 @@ runDashboardRemoteBindTest( forwardLine, `No OpenShell forward found for ${sandboxName} on ${dashboardPort}`, ).not.toBe(""); + expect( + dashboardForwardIsRunning(forwardLine), + `Dashboard forward is not running after connect handoff: ${forwardLine}`, + ).toBe(true); expect( bindsLoopback(forwardLine, dashboardPort), `Dashboard forward is still localhost-only; expected an all-interface bind: ${forwardLine}`, @@ -216,6 +225,30 @@ runDashboardRemoteBindTest( `Could not prove dashboard forward uses 0.0.0.0:${dashboardPort}: ${forwardLine}`, ).toBe(true); + const forwardReachable = await host.command( + process.execPath, + [ + "-e", + [ + 'const net = require("node:net");', + "const socket = net.connect({ host: '127.0.0.1', port: Number(process.argv[1]) });", + "const deadline = setTimeout(() => { socket.destroy(); process.exit(1); }, 5000);", + "socket.once('connect', () => { clearTimeout(deadline); socket.destroy(); process.exit(0); });", + "socket.once('error', () => { clearTimeout(deadline); process.exit(1); });", + ].join("\n"), + dashboardPort, + ], + { + artifactName: "dashboard-remote-bind-post-handoff-reachability", + env: testEnv(), + timeoutMs: 10_000, + }, + ); + expect( + forwardReachable.exitCode, + `Dashboard forward is unreachable after connect handoff\n${resultText(forwardReachable)}`, + ).toBe(0); + progress.phase("audit exposed dashboard controls"); const audit = await sandbox.execShell( sandboxName, diff --git a/test/e2e/live/dcode-base-image-runtime-evidence.ts b/test/e2e/live/dcode-base-image-runtime-evidence.ts index dd8be5d0957..ed219ca0f7f 100644 --- a/test/e2e/live/dcode-base-image-runtime-evidence.ts +++ b/test/e2e/live/dcode-base-image-runtime-evidence.ts @@ -91,6 +91,15 @@ export function dcodeBaseImageReferenceForContract(contract: DcodeBaseImageContr return contract.platformReferences[DCODE_BASE_IMAGE_TARGET_PLATFORM]; } +function requireDcodeSourceRevision(metadata: SandboxBaseImageResolutionMetadata): string { + if (!metadata.sourceRevision || !REVISION_PATTERN.test(metadata.sourceRevision)) { + throw new Error( + `Deep Agents Code sandbox image does not match the published ${DCODE_BASE_IMAGE_TARGET_PLATFORM} base-image contract (mismatched fields: source revision)`, + ); + } + return metadata.sourceRevision; +} + export function loadDcodeBaseImagePublicationEvidence( targetId: string, evidencePath: string, @@ -128,6 +137,7 @@ export function verifyDcodeBaseImageRuntimeEvidence( } const expectedDigest = contract.platformDigests[DCODE_BASE_IMAGE_TARGET_PLATFORM]; const expectedReference = dcodeBaseImageReferenceForContract(contract); + const sourceRevision = requireDcodeSourceRevision(metadata); const mismatchedFields = [ metadata.schema !== 1 ? "schema" : null, metadata.imageName !== contract.image ? "image" : null, @@ -136,6 +146,7 @@ export function verifyDcodeBaseImageRuntimeEvidence( metadata.digest !== expectedDigest ? "digest" : null, metadata.ref !== expectedReference ? "reference" : null, metadata.ref !== `${metadata.imageName}@${metadata.digest}` ? "reference binding" : null, + sourceRevision !== contract.sourceRevision ? "source revision" : null, ].filter((field): field is string => field !== null); if (mismatchedFields.length > 0) { throw new Error( @@ -151,7 +162,7 @@ export function verifyDcodeBaseImageRuntimeEvidence( reference: expectedReference, sandboxImage, source: "override", - sourceRevision: contract.sourceRevision, + sourceRevision, }; } diff --git a/test/e2e/live/gateway-guard-legacy-keepalive-fixture.ts b/test/e2e/live/gateway-guard-legacy-keepalive-fixture.ts index 8cc7872f53a..6007aba571f 100644 --- a/test/e2e/live/gateway-guard-legacy-keepalive-fixture.ts +++ b/test/e2e/live/gateway-guard-legacy-keepalive-fixture.ts @@ -85,10 +85,6 @@ function hasExactTokens(value: unknown, expected: readonly string[]): boolean { ); } -function isReviewedEmptyCommand(value: unknown): boolean { - return value === null || value === undefined || hasExactTokens(value, []); -} - function reviewedManagedRuntimeWorkload(environment: unknown): string[] | null { if (!Array.isArray(environment) || !environment.every((entry) => typeof entry === "string")) { return null; @@ -122,11 +118,16 @@ function reviewedManagedRuntimeWorkload(environment: unknown): string[] | null { } } -function hasReviewedManagedRuntimeProcess(config: Record): boolean { +function hasReviewedOpenShellManagedSource( + config: Record, + managedWorkload: readonly string[] | null, +): boolean { + const labels = config.Labels; return ( - hasExactTokens(config.Entrypoint, OPENSHELL_SANDBOX_ENTRYPOINT) && - (isReviewedEmptyCommand(config.Cmd) || hasExactTokens(config.Cmd, OPENSHELL_WORKDIR_COMMAND)) && - reviewedManagedRuntimeWorkload(config.Env) !== null + managedWorkload !== null && + typeof labels === "object" && + labels !== null && + (labels as Record)["openshell.ai/managed-by"] === "openshell" ); } @@ -161,7 +162,7 @@ export function rewriteManagedInspectForLegacyKeepalive( ); const configRecord = config as Record; const managedWorkload = reviewedManagedRuntimeWorkload(configRecord.Env); - const isManagedRuntimeSource = hasReviewedManagedRuntimeProcess(configRecord); + const isManagedRuntimeSource = hasReviewedOpenShellManagedSource(configRecord, managedWorkload); requireFixtureInput( (hasExactTokens(configRecord.Entrypoint, MANAGED_IMAGE_ENTRYPOINT) && hasExactTokens(configRecord.Cmd, MANAGED_IMAGE_COMMAND)) || diff --git a/test/e2e/live/hermes-gpu-startup-proof.ts b/test/e2e/live/hermes-gpu-startup-proof.ts index 3eb19f2f525..703d8f6fe70 100644 --- a/test/e2e/live/hermes-gpu-startup-proof.ts +++ b/test/e2e/live/hermes-gpu-startup-proof.ts @@ -1,6 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + type ManagedWorkloadAuthority, + readManagedWorkloadAuthority, +} from "../../../src/lib/onboard/workload/authority.ts"; +import { managedImageRuntimeIdentity } from "../../../src/lib/onboard/managed-image/contract.ts"; +import { assertManagedBootstrapIdentity } from "../../../src/lib/onboard/managed-bootstrap/adapter.ts"; +import { MANAGED_BOOTSTRAP_TRAMPOLINE_EXECUTABLE } from "../../../src/lib/onboard/managed-bootstrap/docker.ts"; +import { MANAGED_BOOTSTRAP_REQUEST_FILE } from "../../../src/lib/onboard/managed-bootstrap/envelope.ts"; +import { fingerprintManagedStartupProfile } from "../../../src/lib/onboard/managed-startup/profile.ts"; +import { OPENSHELL_SANDBOX_SUPERVISOR_ARGV } from "../../../src/lib/onboard/sandbox-create-launch.ts"; +import { load as loadSandboxRegistry } from "../../../src/lib/state/registry/persistence.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { type HostCliClient, @@ -35,56 +46,99 @@ interface HermesGpuStartupProofOptions { status: Pick; } -export async function assertHermesGpuStartupProof({ - env, - gpuRoute, - host, - install, - sandbox, - sandboxName, - status, -}: HermesGpuStartupProofOptions): Promise { - const installText = resultText(install); +const IMMUTABLE_IMAGE_REFERENCE = /^[^@\s]+@sha256:[a-f0-9]{64}$/u; + +export function assertHermesGpuStartupOutputContract( + gpuRoute: HermesGpuStartupProofOptions["gpuRoute"], + installText: string, +): void { expect(installText).toContain("Starting OpenShell Docker-driver gateway..."); expect(installText).toContain("Docker-driver gateway is healthy"); expect(installText).not.toContain("Reusing healthy NemoClaw gateway."); expect(installText).not.toContain("Reusing existing Docker-driver gateway"); expect(installText).not.toContain("[reuse] Skipping gateway (running)"); - if (gpuRoute === "compatibility-only") { - expect(installText).toContain("Docker container mode selected:"); - for (const fragment of HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS) { - expect(installText).not.toContain(fragment); - } - } else if (gpuRoute === "compatibility-fallback") { + if (gpuRoute === "compatibility-fallback") { expect(installText).toContain( "Operator-authorized GPU fallback enabled; trying native OpenShell injection with one compatibility retry.", ); for (const fragment of HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS) { expect(installText).toContain(fragment); } - expect(installText).toContain("Docker container mode selected:"); } else { - expect(installText).toContain( - "Direct sandbox GPU enabled; allowing OpenShell GPU policy enrichment.", - ); - expect(installText).toContain( - "Docker container mode selected: persistent sandbox startup command", - ); for (const fragment of HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS) { expect(installText).not.toContain(fragment); } } +} + +export function assertHermesManagedWorkloadAuthority( + sandboxName: string, + registryImageTag: string | null | undefined, + authority: ManagedWorkloadAuthority | null, +): string { + if (!authority) { + throw new Error( + `Hermes GPU sandbox '${sandboxName}' has no managed workload authority`, + ); + } + if ( + typeof registryImageTag !== "string" || + typeof authority.receipt.reference !== "string" || + !IMMUTABLE_IMAGE_REFERENCE.test(registryImageTag) || + !IMMUTABLE_IMAGE_REFERENCE.test(authority.receipt.reference) + ) { + throw new Error( + `Hermes GPU sandbox '${sandboxName}' has no immutable image reference`, + ); + } + const authorityReference = authority.receipt.reference; + expect(authority).toMatchObject({ + agent: "hermes", + contract: { + agent: "hermes", + reference: authorityReference, + }, + profile: { agent: "hermes" }, + receipt: { + kind: "managed-image", + reference: registryImageTag, + }, + }); + return authorityReference; +} + +export function assertHermesContainerImageAuthority( + containerImage: unknown, + authorityReference: string, +): void { + expect(containerImage).toBe(authorityReference); +} + +export async function assertHermesGpuStartupProof({ + env, + gpuRoute, + host, + install, + sandbox, + sandboxName, + status, +}: HermesGpuStartupProofOptions): Promise { + const installText = resultText(install); + assertHermesGpuStartupOutputContract(gpuRoute, installText); const plainStatus = stripAnsi(resultText(status)); expect(plainStatus).toMatch(/Phase:\s*Ready/i); expect(plainStatus).toContain("Sandbox GPU: enabled"); expect(plainStatus).toContain("CUDA verified"); expect(plainStatus).not.toMatch(/last CUDA proof failed|CUDA unverified/i); - const openshellState = await sandbox.openshell(["sandbox", "get", sandboxName], { - artifactName: "phase-4-openshell-sandbox-ready-gpu-startup", - env, - timeoutMs: 30_000, - }); + const openshellState = await sandbox.openshell( + ["sandbox", "get", sandboxName], + { + artifactName: "phase-4-openshell-sandbox-ready-gpu-startup", + env, + timeoutMs: 30_000, + }, + ); expect(openshellState.exitCode, resultText(openshellState)).toBe(0); expect(stripAnsi(resultText(openshellState))).toMatch(/Phase:\s*Ready/i); @@ -132,6 +186,19 @@ export async function assertHermesGpuStartupProof({ const [containerId = ""] = containerRows[0].split(/\s+/, 1); expect(containerId).not.toBe(""); + const registryEntry = loadSandboxRegistry().sandboxes[sandboxName]; + if (!registryEntry) { + throw new Error( + `Hermes GPU sandbox '${sandboxName}' is missing from the registry`, + ); + } + const managedAuthority = readManagedWorkloadAuthority(registryEntry); + const managedImageReference = assertHermesManagedWorkloadAuthority( + sandboxName, + registryEntry.imageTag, + managedAuthority, + ); + const expectedExtraPlaceholderAssignment = `NEMOCLAW_EXTRA_PLACEHOLDER_KEYS=${HERMES_GPU_EXTRA_PLACEHOLDER_KEYS.join(",")}`; const extraPlaceholderEnv = await host.command( "docker", @@ -168,7 +235,9 @@ raise SystemExit(1)`, }, ); expect(extraPlaceholderEnv.exitCode, resultText(extraPlaceholderEnv)).toBe(0); - expect(extraPlaceholderEnv.stdout.trim()).toBe(expectedExtraPlaceholderAssignment); + expect(extraPlaceholderEnv.stdout.trim()).toBe( + expectedExtraPlaceholderAssignment, + ); const guardWithoutStartupOwner = await sandbox.execShell( sandboxName, @@ -218,7 +287,7 @@ raise SystemExit(1)`, "bash", [ "-lc", - String.raw`docker inspect "$1" | python3 -c 'import json, sys; config=json.load(sys.stdin)[0]["Config"]; env=dict(item.split("=", 1) for item in (config.get("Env") or []) if "=" in item); command=env.get("OPENSHELL_SANDBOX_COMMAND", ""); tokens=command.split(); print(json.dumps({"cmd": config.get("Cmd"), "entrypoint": config.get("Entrypoint"), "has_openshell_sandbox_command": bool(command), "command_is_sleep_infinity": tokens == ["sleep", "infinity"], "command_ends_with_nemoclaw_start": bool(tokens) and tokens[-1] in ("nemoclaw-start", "/usr/local/bin/nemoclaw-start")}))'`, + String.raw`docker inspect "$1" | python3 -c 'import json, sys; config=json.load(sys.stdin)[0]["Config"]; env=dict(item.split("=", 1) for item in (config.get("Env") or []) if "=" in item); command=env.get("OPENSHELL_SANDBOX_COMMAND", ""); tokens=command.split(); print(json.dumps({"cmd": config.get("Cmd"), "entrypoint": config.get("Entrypoint"), "image": config.get("Image"), "has_openshell_sandbox_command": bool(command), "command_is_sleep_infinity": tokens == ["sleep", "infinity"], "command_ends_with_nemoclaw_start": bool(tokens) and tokens[-1] in ("nemoclaw-start", "/usr/local/bin/nemoclaw-start")}))'`, "hermes-gpu-command-boundary", containerId, ], @@ -228,13 +297,43 @@ raise SystemExit(1)`, timeoutMs: 30_000, }, ); - expect(dockerCommandBoundary.exitCode, resultText(dockerCommandBoundary)).toBe(0); + expect( + dockerCommandBoundary.exitCode, + resultText(dockerCommandBoundary), + ).toBe(0); const commandBoundary = JSON.parse(dockerCommandBoundary.stdout); - expect(commandBoundary).toMatchObject({ - cmd: ["--workdir", "/sandbox"], - entrypoint: ["/opt/openshell/bin/openshell-sandbox"], - has_openshell_sandbox_command: true, - }); + const verifiedManagedAuthority = managedAuthority!; + expect(verifiedManagedAuthority.agent).toBe("hermes"); + const managedBootstrapCommand = commandBoundary.cmd; + expect(Array.isArray(managedBootstrapCommand)).toBe(true); + const bootstrapIdentity = managedBootstrapCommand[5]; + expect(typeof bootstrapIdentity).toBe("string"); + assertManagedBootstrapIdentity(bootstrapIdentity); + const agentIdentity = managedImageRuntimeIdentity(verifiedManagedAuthority.agent); + expect(commandBoundary.entrypoint).toEqual([MANAGED_BOOTSTRAP_TRAMPOLINE_EXECUTABLE]); + expect(managedBootstrapCommand).toEqual([ + "--agent", + verifiedManagedAuthority.agent, + "--profile-fingerprint", + fingerprintManagedStartupProfile(verifiedManagedAuthority.profile), + "--bootstrap-identity", + bootstrapIdentity, + "--agent-uid", + String(agentIdentity.uid), + "--agent-gid", + String(agentIdentity.gid), + "--agent-workdir", + agentIdentity.workdir, + "--request-file", + MANAGED_BOOTSTRAP_REQUEST_FILE, + "--", + ...OPENSHELL_SANDBOX_SUPERVISOR_ARGV, + ]); + expect(commandBoundary.has_openshell_sandbox_command).toBe(true); + assertHermesContainerImageAuthority( + commandBoundary.image, + managedImageReference, + ); expect(commandBoundary.command_ends_with_nemoclaw_start).toBe(true); expect(commandBoundary.command_is_sleep_infinity).toBe(false); @@ -272,5 +371,7 @@ raise SystemExit(1)`, .map((line) => line.trim()) .filter(Boolean); expect(allContainerNames).toHaveLength(1); - expect(allContainerNames.filter((name) => name.includes("-nemoclaw-gpu-backup-"))).toEqual([]); + expect( + allContainerNames.filter((name) => name.includes("-nemoclaw-gpu-backup-")), + ).toEqual([]); } diff --git a/test/e2e/live/hermes-gpu-startup.test.ts b/test/e2e/live/hermes-gpu-startup.test.ts index 81041bfe685..d7ad88ec214 100644 --- a/test/e2e/live/hermes-gpu-startup.test.ts +++ b/test/e2e/live/hermes-gpu-startup.test.ts @@ -548,6 +548,7 @@ test( : { compatibilityOnlyRouteVerified: true }), openshellReady: true, sandboxCudaVerified: true, + managedWorkloadAuthorityVerified: true, extraPlaceholderCommandRoundTripValid: true, stableSingleContainer: true, startupConfigHashesValid: true, diff --git a/test/e2e/live/managed-image-activation-e2e-helpers.ts b/test/e2e/live/managed-image-activation-e2e-helpers.ts index 58b7e2d3e05..41aebeeb0fd 100644 --- a/test/e2e/live/managed-image-activation-e2e-helpers.ts +++ b/test/e2e/live/managed-image-activation-e2e-helpers.ts @@ -100,6 +100,27 @@ type DockerGuard = { readonly dispose: () => void; }; +export function managedActivationOnboardArgs( + catalogPath: string, + agent: ShippedManagedImageAgent, + sandboxName: string, +): string[] { + return [ + "onboard", + "--temp-managed-runtime-catalog", + catalogPath, + "--fresh", + "--recreate-sandbox", + "--non-interactive", + "--yes", + "--no-gpu", + "--agent", + agent, + "--name", + sandboxName, + ]; +} + function requiredCatalogPath(): string { const value = process.env.NEMOCLAW_MANAGED_ACTIVATION_CATALOG; if (!value || !path.isAbsolute(value)) { @@ -465,21 +486,7 @@ async function qualifyAgent( enterOnboardPhase(progress, agent); const onboard = await host.nemoclaw( - [ - "onboard", - "--temp-managed-runtime", - "--temp-managed-runtime-catalog", - catalogPath, - "--fresh", - "--recreate-sandbox", - "--non-interactive", - "--yes", - "--no-gpu", - "--agent", - agent, - "--name", - sandboxName, - ], + managedActivationOnboardArgs(catalogPath, agent, sandboxName), { artifactName: `managed-activation-onboard-${agent}`, env, diff --git a/test/e2e/live/mcp-bridge-servers.ts b/test/e2e/live/mcp-bridge-servers.ts index 8b12ea87cc1..e8a657d07ef 100644 --- a/test/e2e/live/mcp-bridge-servers.ts +++ b/test/e2e/live/mcp-bridge-servers.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { ChildProcess } from "node:child_process"; +import { randomBytes } from "node:crypto"; import fs from "node:fs"; import http from "node:http"; import https from "node:https"; @@ -40,11 +41,17 @@ export interface FakeMcpRequest { responseHasResult?: boolean; negotiatedSessionId?: string; negotiatedProtocolVersion?: string; + legacySessionId?: string; + negotiatedLegacySessionId?: string; + legacyPhase?: LegacyMcpSessionPhase; + legacyResponseSequence?: number; + rpcId?: string | number | null; } export interface FakeMcpHttpsServer extends StartedHttpServer { setSecret(secret: string): void; requests: FakeMcpRequest[]; + activeLegacySessionCount(): number; } export interface StartedPublicMcpTunnel { @@ -61,6 +68,23 @@ interface McpRequestPayload { params?: { name?: unknown; arguments?: { challenge?: unknown }; cursor?: unknown }; } +export type LegacyMcpSessionPhase = "opened" | "awaiting-initialized" | "ready" | "closed"; + +interface LegacyMcpSession { + id: string; + response: http.ServerResponse; + phase: LegacyMcpSessionPhase; + protocolVersion?: string; + pendingRequestIds: Set; + queuedBytes: number; + responseSequence: number; + writeChain: Promise; +} + +type LegacyQueueResult = + | { ok: true; sequence: number } + | { ok: false; status: number; message: string }; + const MCP_NOTIFICATION_METHODS = new Set([ "notifications/initialized", "notifications/cancelled", @@ -69,6 +93,9 @@ const MCP_NOTIFICATION_METHODS = new Set([ "notifications/elicitation/complete", ]); +const LEGACY_MCP_SESSION_BYTES = 32; +const LEGACY_MCP_MAX_QUEUED_BYTES = 64 * 1024; + const TRYCLOUDFLARE_ORIGIN_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com(?=$|[\s"'\\/])/i; const QUICK_TUNNEL_ATTEMPTS = 3; const QUICK_TUNNEL_ATTEMPT_TIMEOUT_MS = 45_000; @@ -135,6 +162,89 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function jsonRpcId(value: unknown): string | number | null | undefined { + if (value === null || typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + return undefined; +} + +function jsonRpcIdKey(value: string | number | null): string { + return `${value === null ? "null" : typeof value}:${String(value)}`; +} + +function waitForLegacyMcpDrain(response: http.ServerResponse): Promise { + return new Promise((resolve, reject) => { + const cleanup = (): void => { + response.off("drain", onDrain); + response.off("close", onClose); + response.off("error", onError); + }; + const onDrain = (): void => { + cleanup(); + resolve(); + }; + const onClose = (): void => { + cleanup(); + reject(new Error("legacy MCP event stream closed during backpressure")); + }; + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + response.once("drain", onDrain); + response.once("close", onClose); + response.once("error", onError); + }); +} + +function queueLegacyMcpResponse( + session: LegacyMcpSession, + requestId: string | number | null, + payload: unknown, +): LegacyQueueResult { + if ( + session.phase === "closed" || + session.response.destroyed || + session.response.writableEnded + ) { + return { ok: false, status: 410, message: "legacy MCP event stream is closed" }; + } + const requestIdKey = jsonRpcIdKey(requestId); + if (session.pendingRequestIds.has(requestIdKey)) { + return { ok: false, status: 409, message: "legacy MCP request ID is already pending" }; + } + const event = `data: ${JSON.stringify(payload)}\n\n`; + const eventBytes = Buffer.byteLength(event); + if (session.queuedBytes + eventBytes > LEGACY_MCP_MAX_QUEUED_BYTES) { + return { ok: false, status: 429, message: "legacy MCP response queue is full" }; + } + + session.pendingRequestIds.add(requestIdKey); + session.queuedBytes += eventBytes; + session.responseSequence += 1; + const sequence = session.responseSequence; + session.writeChain = session.writeChain + .then(async () => { + if ( + session.phase === "closed" || + session.response.destroyed || + session.response.writableEnded + ) { + throw new Error("legacy MCP event stream closed before response delivery"); + } + if (!session.response.write(event)) await waitForLegacyMcpDrain(session.response); + }) + .catch(() => { + session.phase = "closed"; + session.response.destroy(); + }) + .finally(() => { + session.pendingRequestIds.delete(requestIdKey); + session.queuedBytes -= eventBytes; + }); + return { ok: true, sequence }; +} + function buildCloudflaredSubprocessEnv(): Record { const env: Record = { // Do not let quick-tunnel discovery consume a developer's named-tunnel @@ -656,6 +766,8 @@ export async function startFakeMcpHttpsServer(options: { let expectedSecret = options.secret; let nextSessionId = 1; const sessions = new Map(); + const legacySessions = new Map(); + const serverEventStreams = new Set(); const tls = options.tls ?? (() => { @@ -670,7 +782,10 @@ export async function startFakeMcpHttpsServer(options: { })(); const requests: FakeMcpRequest[] = []; const server = https.createServer(tls, async (req, res) => { - const requestPath = new URL(req.url ?? "/", "https://fake-mcp.local").pathname; + const requestUrl = new URL(req.url ?? "/", "https://fake-mcp.local"); + const requestPath = requestUrl.pathname; + const legacySessionId = requestUrl.searchParams.get("legacySessionId") ?? ""; + const legacySession = legacySessionId ? legacySessions.get(legacySessionId) : undefined; const body = await readRequestBody(req); const auth = Array.isArray(req.headers.authorization) ? req.headers.authorization.join(",") @@ -692,6 +807,7 @@ export async function startFakeMcpHttpsServer(options: { // assertions continue to measure only attempted MCP traffic. let recordedRequest: FakeMcpRequest | undefined; if (req.method !== "HEAD") { + const requestId = jsonRpcId(parsedPayload?.id); recordedRequest = { method: req.method ?? "", path: requestPath, @@ -699,6 +815,9 @@ export async function startFakeMcpHttpsServer(options: { body, sessionId, protocolVersion, + ...(legacySessionId ? { legacySessionId } : {}), + ...(legacySession ? { legacyPhase: legacySession.phase } : {}), + ...(requestId !== undefined ? { rpcId: requestId } : {}), ...(typeof parsedPayload?.method === "string" ? { rpcMethod: parsedPayload.method } : {}), }; requests.push(recordedRequest); @@ -719,14 +838,96 @@ export async function startFakeMcpHttpsServer(options: { res.writeHead(status, headers); res.end(); }; + const respondRpc = (requestId: string | number | null, payload: unknown): void => { + if (!legacySessionId) { + respondJson(200, payload); + return; + } + const activeLegacySession = legacySessions.get(legacySessionId); + if (!activeLegacySession) { + respondJson(404, { error: { message: "legacy MCP event stream is unavailable" } }); + return; + } + const queued = queueLegacyMcpResponse(activeLegacySession, requestId, payload); + if (!queued.ok) { + respondJson(queued.status, { error: { message: queued.message } }); + return; + } + if (recordedRequest) { + recordedRequest.responseStatus = 202; + recordedRequest.legacyResponseSequence = queued.sequence; + recordedRequest.responseHasResult = + typeof payload === "object" && + payload !== null && + Object.prototype.hasOwnProperty.call(payload, "result") && + !Object.prototype.hasOwnProperty.call(payload, "error"); + } + res.writeHead(202); + res.end(); + }; if (requestPath !== "/mcp") { respondJson(404, { error: { message: "not found" } }); return; } - if (req.method === "HEAD" || req.method === "GET") { + if (req.method === "HEAD") { respondEmpty(405, { Allow: "POST" }); return; } + if (req.method === "GET") { + if (auth !== `Bearer ${expectedSecret}`) { + respondJson(401, { error: { message: "missing rewritten bearer credential" } }); + return; + } + if (sessionId === "" && protocolVersion === "") { + let eventSessionId: string; + do { + eventSessionId = randomBytes(LEGACY_MCP_SESSION_BYTES).toString("base64url"); + } while (legacySessions.has(eventSessionId)); + const eventSession: LegacyMcpSession = { + id: eventSessionId, + response: res, + phase: "opened", + pendingRequestIds: new Set(), + queuedBytes: 0, + responseSequence: 0, + writeChain: Promise.resolve(), + }; + legacySessions.set(eventSessionId, eventSession); + if (recordedRequest) { + recordedRequest.responseStatus = 200; + recordedRequest.negotiatedLegacySessionId = eventSessionId; + recordedRequest.legacyPhase = eventSession.phase; + } + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + res.write(`event: endpoint\ndata: /mcp?legacySessionId=${eventSessionId}\n\n`); + serverEventStreams.add(res); + res.once("close", () => { + eventSession.phase = "closed"; + legacySessions.delete(eventSessionId); + serverEventStreams.delete(res); + }); + return; + } + const negotiatedProtocolVersion = sessions.get(sessionId); + if (!negotiatedProtocolVersion || protocolVersion !== negotiatedProtocolVersion) { + respondJson(400, { error: { message: "missing negotiated MCP session metadata" } }); + return; + } + if (recordedRequest) recordedRequest.responseStatus = 200; + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + res.write(": connected\n\n"); + serverEventStreams.add(res); + res.once("close", () => serverEventStreams.delete(res)); + return; + } if (req.method !== "POST" && req.method !== "DELETE") { respondJson(405, { error: { message: "method not allowed" } }); return; @@ -736,6 +937,10 @@ export async function startFakeMcpHttpsServer(options: { return; } if (req.method === "DELETE") { + if (legacySessionId) { + respondJson(405, { error: { message: "legacy MCP sessions close with the event stream" } }); + return; + } const negotiatedProtocolVersion = sessions.get(sessionId); if (!negotiatedProtocolVersion || protocolVersion !== negotiatedProtocolVersion) { respondJson(400, { error: { message: "missing negotiated MCP session metadata" } }); @@ -750,21 +955,79 @@ export async function startFakeMcpHttpsServer(options: { respondJson(400, { error: { message: "invalid json" } }); return; } + if (legacySessionId && !legacySession) { + respondJson(404, { error: { message: "legacy MCP event stream is unavailable" } }); + return; + } + if ( + legacySession && + (legacySession.phase === "closed" || !legacySessions.has(legacySessionId)) + ) { + respondJson(410, { error: { message: "legacy MCP event stream is closed" } }); + return; + } + const requestId = jsonRpcId(parsedPayload.id); + const isNotification = + typeof parsedPayload.method === "string" && MCP_NOTIFICATION_METHODS.has(parsedPayload.method); + if (legacySession) { + if (sessionId !== "") { + respondJson(400, { error: { message: "legacy MCP requests must not mix session headers" } }); + return; + } + if (parsedPayload.method === "initialize") { + if (legacySession.phase !== "opened") { + respondJson(409, { error: { message: "legacy MCP session is already initialized" } }); + return; + } + if (protocolVersion !== "") { + respondJson(400, { error: { message: "legacy MCP initialize sent premature metadata" } }); + return; + } + } else { + if (legacySession.phase === "opened") { + respondJson(409, { error: { message: "legacy MCP session is not initialized" } }); + return; + } + if (!legacySession.protocolVersion || protocolVersion !== legacySession.protocolVersion) { + respondJson(400, { error: { message: "missing negotiated legacy MCP metadata" } }); + return; + } + if (parsedPayload.method === "notifications/initialized") { + if (legacySession.phase !== "awaiting-initialized") { + respondJson(409, { error: { message: "legacy MCP initialization phase is invalid" } }); + return; + } + } else if (legacySession.phase !== "ready") { + respondJson(409, { error: { message: "legacy MCP session is not ready" } }); + return; + } + } + if (!isNotification && requestId === undefined) { + respondJson(400, { error: { message: "legacy MCP request ID is required" } }); + return; + } + } + const responseId = requestId === undefined ? 1 : requestId; // This shared fixture also serves intentional stateless policy probes. // Validate any supplied session metadata as an all-or-nothing pair; the // focused discovery assertion separately requires the negotiated pair on // every post-initialize request. - if (parsedPayload.method !== "initialize" && (sessionId !== "" || protocolVersion !== "")) { + if ( + !legacySessionId && + parsedPayload.method !== "initialize" && + (sessionId !== "" || protocolVersion !== "") + ) { const negotiatedProtocolVersion = sessions.get(sessionId); if (!negotiatedProtocolVersion || protocolVersion !== negotiatedProtocolVersion) { respondJson(400, { error: { message: "missing negotiated MCP session metadata" } }); return; } } - if ( - typeof parsedPayload.method === "string" && - MCP_NOTIFICATION_METHODS.has(parsedPayload.method) - ) { + if (isNotification) { + if (legacySession && parsedPayload.method === "notifications/initialized") { + legacySession.phase = "ready"; + if (recordedRequest) recordedRequest.legacyPhase = legacySession.phase; + } respondEmpty(202); return; } @@ -774,13 +1037,22 @@ export async function startFakeMcpHttpsServer(options: { params?: { protocolVersion?: string }; }; const negotiatedProtocolVersion = request.params?.protocolVersion ?? "2025-03-26"; - const negotiatedSessionId = `fake-session-${nextSessionId}`; - nextSessionId += 1; - sessions.set(negotiatedSessionId, negotiatedProtocolVersion); - res.setHeader("mcp-session-id", negotiatedSessionId); - if (recordedRequest) { - recordedRequest.negotiatedSessionId = negotiatedSessionId; - recordedRequest.negotiatedProtocolVersion = negotiatedProtocolVersion; + if (legacySession) { + legacySession.protocolVersion = negotiatedProtocolVersion; + legacySession.phase = "awaiting-initialized"; + if (recordedRequest) { + recordedRequest.negotiatedProtocolVersion = negotiatedProtocolVersion; + recordedRequest.legacyPhase = legacySession.phase; + } + } else { + const negotiatedSessionId = `fake-session-${nextSessionId}`; + nextSessionId += 1; + sessions.set(negotiatedSessionId, negotiatedProtocolVersion); + res.setHeader("mcp-session-id", negotiatedSessionId); + if (recordedRequest) { + recordedRequest.negotiatedSessionId = negotiatedSessionId; + recordedRequest.negotiatedProtocolVersion = negotiatedProtocolVersion; + } } result = { protocolVersion: negotiatedProtocolVersion, @@ -817,9 +1089,9 @@ export async function startFakeMcpHttpsServer(options: { ], }; } else { - respondJson(200, { + respondRpc(responseId, { jsonrpc: "2.0", - id: parsedPayload.id ?? 1, + id: responseId, error: { code: -32602, message: "invalid tools/list cursor" }, }); return; @@ -830,9 +1102,9 @@ export async function startFakeMcpHttpsServer(options: { parsedPayload.params?.name !== "fake_echo" || (options.challenge !== undefined && challenge !== options.challenge) ) { - respondJson(200, { + respondRpc(responseId, { jsonrpc: "2.0", - id: parsedPayload.id ?? 1, + id: responseId, error: { code: -32602, message: "invalid fake_echo challenge" }, }); return; @@ -852,16 +1124,16 @@ export async function startFakeMcpHttpsServer(options: { ) { result = MCP_EMPTY_RESULT_BY_METHOD[parsedPayload.method]; } else { - respondJson(200, { + respondRpc(responseId, { jsonrpc: "2.0", - id: parsedPayload.id ?? 1, + id: responseId, error: { code: -32601, message: "method not found" }, }); return; } - respondJson(200, { + respondRpc(responseId, { jsonrpc: "2.0", - id: parsedPayload.id ?? 1, + id: responseId, result, }); }); @@ -870,9 +1142,15 @@ export async function startFakeMcpHttpsServer(options: { return { port: requireTcpPort(server, "fake MCP endpoint"), requests, + activeLegacySessionCount: () => legacySessions.size, setSecret: (secret: string) => { expectedSecret = secret; }, - close: () => closeServer(server), + close: async () => { + for (const response of serverEventStreams) response.destroy(); + await closeServer(server); + for (const session of legacySessions.values()) session.phase = "closed"; + legacySessions.clear(); + }, }; } diff --git a/test/e2e/live/mcp-bridge-tool-discovery.ts b/test/e2e/live/mcp-bridge-tool-discovery.ts index f0e2db0b76e..416734f13d2 100644 --- a/test/e2e/live/mcp-bridge-tool-discovery.ts +++ b/test/e2e/live/mcp-bridge-tool-discovery.ts @@ -36,7 +36,11 @@ export function shouldRetryMcpToolDiscoveryTransportFailure( export function shouldRetryMcpDiscoveryAfterRestart( requestsSinceAttempt: readonly FakeMcpRequest[], ): boolean { - return requestsSinceAttempt.length === 0; + // Status/readiness checks can hit the configured endpoint without speaking + // MCP. Those probes must not suppress the one bounded runtime restart: only + // fixture-visible MCP protocol traffic proves that the agent attempted + // discovery and produced a product failure worth preserving as-is. + return !requestsSinceAttempt.some((request) => request.rpcMethod !== undefined); } type McpToolDiscoveryStatusJson = { @@ -105,13 +109,22 @@ function buildMcpToolDiscoveryDiagnostics( requests: requests.map((request) => ({ httpMethod: request.method, rpcMethod: request.rpcMethod ?? null, + transport: + request.legacySessionId || request.negotiatedLegacySessionId + ? "legacy-sse" + : "streamable-http", responseStatus: request.responseStatus ?? null, responseHasResult: request.responseHasResult ?? null, + rpcIdPresent: request.rpcId !== undefined, + legacyPhase: request.legacyPhase ?? null, + legacyResponseSequence: request.legacyResponseSequence ?? null, sessionMetadataPresent: { sessionId: Boolean(request.sessionId), protocolVersion: Boolean(request.protocolVersion), negotiatedSessionId: Boolean(request.negotiatedSessionId), negotiatedProtocolVersion: Boolean(request.negotiatedProtocolVersion), + legacySessionId: Boolean(request.legacySessionId), + negotiatedLegacySessionId: Boolean(request.negotiatedLegacySessionId), }, credentialRewriteMatched: request.auth === `Bearer ${expectedSecret}`, })), @@ -134,26 +147,71 @@ export function hasSuccessfulAuthenticatedMcpDiscovery( requests: readonly FakeMcpRequest[], expectedSecret: string, ): boolean { - const authenticatedRequests = requests.filter( - (request) => - request.method === "POST" && - request.path === "/mcp" && - request.auth === `Bearer ${expectedSecret}`, - ); - for (const [initializeIndex, initializeRequest] of authenticatedRequests.entries()) { + const isAuthenticatedMcpRequest = (request: FakeMcpRequest): boolean => + request.path === "/mcp" && request.auth === `Bearer ${expectedSecret}`; + for (const [initializeIndex, initializeRequest] of requests.entries()) { if ( + !isAuthenticatedMcpRequest(initializeRequest) || + initializeRequest.method !== "POST" || initializeRequest.rpcMethod !== "initialize" || - initializeRequest.responseStatus !== 200 || initializeRequest.responseHasResult !== true || - !initializeRequest.negotiatedSessionId || !initializeRequest.negotiatedProtocolVersion ) { continue; } + if (initializeRequest.legacySessionId) { + if ( + initializeRequest.responseStatus !== 202 || + initializeRequest.sessionId !== "" || + initializeRequest.protocolVersion !== "" || + initializeRequest.rpcId === undefined + ) { + continue; + } + const eventStreamIndex = requests.findIndex( + (request, requestIndex) => + requestIndex < initializeIndex && + isAuthenticatedMcpRequest(request) && + request.method === "GET" && + request.responseStatus === 200 && + request.negotiatedLegacySessionId === initializeRequest.legacySessionId, + ); + if (eventStreamIndex === -1) continue; + const hasNegotiatedLegacyMetadata = (request: FakeMcpRequest): boolean => + isAuthenticatedMcpRequest(request) && + request.method === "POST" && + request.legacySessionId === initializeRequest.legacySessionId && + request.sessionId === "" && + request.protocolVersion === initializeRequest.negotiatedProtocolVersion; + const initializedIndex = requests.findIndex( + (request, requestIndex) => + requestIndex > initializeIndex && + request.rpcMethod === "notifications/initialized" && + request.responseStatus === 202 && + hasNegotiatedLegacyMetadata(request), + ); + if (initializedIndex === -1) continue; + const toolsListed = requests.some( + (request, requestIndex) => + requestIndex > initializedIndex && + request.rpcMethod === "tools/list" && + request.rpcId !== undefined && + request.responseStatus === 202 && + request.responseHasResult === true && + hasNegotiatedLegacyMetadata(request), + ); + if (toolsListed) return true; + continue; + } + if (initializeRequest.responseStatus !== 200 || !initializeRequest.negotiatedSessionId) { + continue; + } const hasNegotiatedMetadata = (request: FakeMcpRequest) => + isAuthenticatedMcpRequest(request) && + request.method === "POST" && request.sessionId === initializeRequest.negotiatedSessionId && request.protocolVersion === initializeRequest.negotiatedProtocolVersion; - const initializedIndex = authenticatedRequests.findIndex( + const initializedIndex = requests.findIndex( (request, requestIndex) => requestIndex > initializeIndex && request.rpcMethod === "notifications/initialized" && @@ -161,7 +219,7 @@ export function hasSuccessfulAuthenticatedMcpDiscovery( hasNegotiatedMetadata(request), ); if (initializedIndex === -1) continue; - const toolsListed = authenticatedRequests.some( + const toolsListed = requests.some( (request, requestIndex) => requestIndex > initializedIndex && request.rpcMethod === "tools/list" && @@ -199,6 +257,11 @@ export async function assertAuthenticatedMcpDiscovery( responseHasResult: request.responseHasResult, negotiatedSessionId: request.negotiatedSessionId, negotiatedProtocolVersion: request.negotiatedProtocolVersion, + legacySessionId: request.legacySessionId, + negotiatedLegacySessionId: request.negotiatedLegacySessionId, + legacyPhase: request.legacyPhase, + legacyResponseSequence: request.legacyResponseSequence, + rpcId: request.rpcId, })), }; }, @@ -326,12 +389,47 @@ export async function assertAuthenticatedMcpToolDiscovery( firstToolListIndex, "authenticated MCP discovery must finish initialization before listing tools", ).toBeGreaterThan(initializedIndex); + const initializeRequest = discoveryRpcRequests[initializeIndex]; const initializedRequest = discoveryRpcRequests[initializedIndex]; - expect(initializedRequest.sessionId).toMatch(/^fake-session-\d+$/u); - expect(initializedRequest.protocolVersion).not.toBe(""); - for (const request of discoveryRpcRequests.slice(initializedIndex)) { - expect(request.sessionId).toBe(initializedRequest.sessionId); - expect(request.protocolVersion).toBe(initializedRequest.protocolVersion); + if (initializeRequest.legacySessionId) { + expect(initializeRequest.responseStatus).toBe(202); + expect(initializeRequest.responseHasResult).toBe(true); + expect(initializeRequest.rpcId).not.toBeUndefined(); + expect(initializeRequest.sessionId).toBe(""); + expect(initializeRequest.protocolVersion).toBe(""); + expect(initializeRequest.negotiatedProtocolVersion).not.toBe(""); + const initializeRequestIndex = discoveryRequests.indexOf(initializeRequest); + const eventStreamRequest = discoveryRequests.find( + (request, requestIndex) => + requestIndex < initializeRequestIndex && + request.method === "GET" && + request.path === "/mcp" && + request.auth === `Bearer ${options.hostSecret}` && + request.responseStatus === 200 && + request.negotiatedLegacySessionId === initializeRequest.legacySessionId, + ); + expect( + eventStreamRequest, + "legacy SSE discovery must correlate its authenticated GET with the POST endpoint", + ).toBeDefined(); + for (const request of discoveryRpcRequests.slice(initializedIndex)) { + expect(request.legacySessionId).toBe(initializeRequest.legacySessionId); + expect(request.sessionId).toBe(""); + expect(request.protocolVersion).toBe(initializeRequest.negotiatedProtocolVersion); + } + for (const request of discoveryRpcRequests.filter( + (candidate) => candidate.rpcMethod === "tools/list", + )) { + expect(request.rpcId).not.toBeUndefined(); + expect(request.legacyResponseSequence).toBeGreaterThan(0); + } + } else { + expect(initializedRequest.sessionId).toMatch(/^fake-session-\d+$/u); + expect(initializedRequest.protocolVersion).not.toBe(""); + for (const request of discoveryRpcRequests.slice(initializedIndex)) { + expect(request.sessionId).toBe(initializedRequest.sessionId); + expect(request.protocolVersion).toBe(initializedRequest.protocolVersion); + } } const toolListRequests = discoveryRequests.filter( @@ -342,6 +440,7 @@ export async function assertAuthenticatedMcpToolDiscovery( for (const request of discoveryProtocolRequests.filter( (candidate) => candidate.method === "DELETE", )) { + expect(initializeRequest.legacySessionId).toBeUndefined(); expect(request.sessionId).toBe(initializedRequest.sessionId); expect(request.protocolVersion).toBe(initializedRequest.protocolVersion); } diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index 9ebf6cfafe9..2358f76fa21 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -839,6 +839,7 @@ test("mcp-bridge", { }, ); + const mcporterRequestOffset = fakeMcp.requests.length; const mcporterList = await sandbox.execShell( OPENCLAW_SANDBOX_NAME, trustedSandboxShellScript( @@ -855,6 +856,11 @@ test("mcp-bridge", { ); expectExitZero(mcporterList, "mcporter lists tools through OpenShell MCP policy"); expect(resultText(mcporterList)).toContain("fake_echo"); + await assertAuthenticatedMcpDiscovery(fakeMcp, { + requestOffset: mcporterRequestOffset, + expectedSecret: HOST_SECRET, + label: "mcporter authenticated MCP tool discovery", + }); expect(fakeMcp.requests.some((request) => request.auth === `Bearer ${HOST_SECRET}`)).toBe(true); expect(fakeMcp.requests.every((request) => !request.auth.includes("openshell:resolve:env"))).toBe( true, diff --git a/test/e2e/live/onboard-repair.test.ts b/test/e2e/live/onboard-repair.test.ts index 811edde3445..39ef1544de6 100644 --- a/test/e2e/live/onboard-repair.test.ts +++ b/test/e2e/live/onboard-repair.test.ts @@ -16,6 +16,7 @@ import { cleanupCorporateCaFixture, corporateCaMergeProbeScript, createCorporateCaFixture, + registeredCorporateCaWorkloadKind, } from "../fixtures/corporate-ca.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { readExtraProviders, updateExtraProviders } from "../fixtures/extra-providers-registry.ts"; @@ -359,11 +360,15 @@ test("onboard repair resumes missing sandbox and rejects conflicting resume inpu const status = await nemoclaw(host, [SANDBOX_NAME, "status"], "phase-2-status-after-repair"); expect(status.exitCode, resultText(status)).toBe(0); - const corporateCaProbe = await sandbox.execShell(SANDBOX_NAME, corporateCaMergeProbeScript(), { - artifactName: "phase-2-corporate-ca-merge-probe", - env: env(), - timeoutMs: 60_000, - }); + const corporateCaProbe = await sandbox.execShell( + SANDBOX_NAME, + corporateCaMergeProbeScript(registeredCorporateCaWorkloadKind(SANDBOX_NAME)), + { + artifactName: "phase-2-corporate-ca-merge-probe", + env: env(), + timeoutMs: 60_000, + }, + ); expect(corporateCaProbe.exitCode, resultText(corporateCaProbe)).toBe(0); progress.phase("reseed interrupted onboarding state"); diff --git a/test/e2e/live/onboard-resume.test.ts b/test/e2e/live/onboard-resume.test.ts index b7019c87129..9f22cb0b4fb 100644 --- a/test/e2e/live/onboard-resume.test.ts +++ b/test/e2e/live/onboard-resume.test.ts @@ -20,6 +20,7 @@ import { cleanupCorporateCaFixture, corporateCaMergeProbeScript, createCorporateCaFixture, + registeredCorporateCaWorkloadKind, } from "../fixtures/corporate-ca.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { @@ -541,11 +542,15 @@ test( }); expect(sandboxStatus.exitCode, sandboxStatus.stderr).toBe(0); - const corporateCaProbe = await sandbox.execShell(SANDBOX_NAME, corporateCaMergeProbeScript(), { - artifactName: "phase-3-corporate-ca-merge-probe", - env: probeEnv, - timeoutMs: 60_000, - }); + const corporateCaProbe = await sandbox.execShell( + SANDBOX_NAME, + corporateCaMergeProbeScript(registeredCorporateCaWorkloadKind(SANDBOX_NAME)), + { + artifactName: "phase-3-corporate-ca-merge-probe", + env: probeEnv, + timeoutMs: 60_000, + }, + ); expect(corporateCaProbe.exitCode, resultText(corporateCaProbe)).toBe(0); // Assertion: session-file-complete-state. diff --git a/test/e2e/live/openshell-credential-generation-window.test.ts b/test/e2e/live/openshell-credential-generation-window.test.ts index 70a48590592..bbc9b811aae 100644 --- a/test/e2e/live/openshell-credential-generation-window.test.ts +++ b/test/e2e/live/openshell-credential-generation-window.test.ts @@ -650,26 +650,14 @@ test("openshell-credential-generation-window", { CREDENTIAL_WINDOW_STEPS.fallbackAfterEviction, "credential-window-signal-fallback-after-eviction", ); - await waitForAcknowledgement(sandbox, CREDENTIAL_WINDOW_STEPS.fallbackAfterEviction, "allowed"); - await expect - .poll( - () => - requestEvidence( - fakeMcp, - credentialWindowRequestId(CREDENTIAL_WINDOW_STEPS.fallbackAfterEviction), - rotatedSecret, - ), - { - interval: 500, - timeout: 30_000, - message: "old revision current-key fallback", - }, - ) - .toEqual({ - seen: true, - credentialRewritten: true, - placeholderAbsent: true, - }); + await waitForAcknowledgement(sandbox, CREDENTIAL_WINDOW_STEPS.fallbackAfterEviction, "denied"); + expect( + requestEvidence( + fakeMcp, + credentialWindowRequestId(CREDENTIAL_WINDOW_STEPS.fallbackAfterEviction), + rotatedSecret, + ).seen, + ).toBe(false); const freshAfterEvictionId = `${CREDENTIAL_WINDOW_REQUEST_PREFIX}:fresh-after-eviction`; const freshAfterEviction = await runFreshRequest( @@ -838,7 +826,7 @@ test("openshell-credential-generation-window", { outcomes: [ { step: CREDENTIAL_WINDOW_STEPS.fallbackAfterEviction, - outcome: "allowed", + outcome: "denied", }, { step: CREDENTIAL_WINDOW_STEPS.deniedAfterKeyRemoval, outcome: "denied" }, { step: CREDENTIAL_WINDOW_STEPS.deniedAfterDetach, outcome: "denied" }, @@ -909,6 +897,9 @@ test("openshell-credential-generation-window", { expect(upstreamRequestIds).not.toContain( credentialWindowRequestId(CREDENTIAL_WINDOW_STEPS.deniedAfterExpiry), ); + expect(upstreamRequestIds).not.toContain( + credentialWindowRequestId(CREDENTIAL_WINDOW_STEPS.fallbackAfterEviction), + ); expect(upstreamRequestIds).not.toContain( credentialWindowRequestId(CREDENTIAL_WINDOW_STEPS.deniedAfterKeyRemoval), ); diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 7751e59c525..04b9435bab3 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -101,6 +101,7 @@ "fast": [ "test/e2e/support/hermes-gpu-startup-fallback.test.ts", "test/e2e/support/hermes-gpu-startup-integrity.test.ts", + "test/e2e/support/hermes-gpu-startup-proof.test.ts", "test/e2e/support/hermes-workflow-boundary.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" @@ -229,9 +230,12 @@ "src/lib/onboard/extra-provider-reconciliation-diagnostics.test.ts", "src/lib/onboard/extra-provider-reconciliation-probes.test.ts", "src/lib/onboard/extra-provider-reconciliation.test.ts", + "src/lib/onboard/initial-policy-real-policy.test.ts", "src/lib/onboard/machine/handlers/sandbox-resume.test.ts", + "src/lib/onboard/managed-startup-image-runtime-handoff.test.ts", "src/lib/onboard/sandbox-create-plan.test.ts", "test/onboard-extra-provider-reconciliation.test.ts", + "test/e2e/support/corporate-ca-workload-kind.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" ] @@ -242,12 +246,15 @@ "src/lib/onboard/extra-provider-reconciliation-diagnostics.test.ts", "src/lib/onboard/extra-provider-reconciliation-probes.test.ts", "src/lib/onboard/extra-provider-reconciliation.test.ts", + "src/lib/onboard/initial-policy-real-policy.test.ts", "src/lib/onboard/machine/handlers/sandbox-recreate-resume.test.ts", "src/lib/onboard/machine/handlers/sandbox-resume.test.ts", + "src/lib/onboard/managed-startup-image-runtime-handoff.test.ts", "src/lib/onboard/sandbox-create-plan.test.ts", "src/lib/onboard/sandbox-gpu-create-flow.test.ts", "src/lib/onboard/sandbox-readiness-tracing.test.ts", "test/onboard-extra-provider-reconciliation.test.ts", + "test/e2e/support/corporate-ca-workload-kind.test.ts", "test/gateway-state.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts", @@ -307,6 +314,9 @@ { "live": "test/e2e/live/cloud-onboard.test.ts", "fast": [ + "src/lib/onboard/initial-policy-real-policy.test.ts", + "src/lib/onboard/managed-startup-image-runtime-handoff.test.ts", + "test/e2e/support/corporate-ca-workload-kind.test.ts", "test/e2e/support/e2e-cleanup-resources.test.ts", "test/e2e/support/e2e-clients.test.ts" ] 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 72c27fd5410..23c3570d682 100644 --- a/test/e2e/support/base-image-publication-workflow-boundary.test.ts +++ b/test/e2e/support/base-image-publication-workflow-boundary.test.ts @@ -107,20 +107,35 @@ describe("base-image publication workflow boundary (#7372)", () => { }); it.each([ - ["push to main", "push", "", "1"], - ["manual main", "workflow_dispatch", "", "1"], - ["controller-selected PR", "workflow_dispatch", "a".repeat(40), "1"], + ["push to main", "push", "", "refs/heads/main", "1", "0"], + ["manual main", "workflow_dispatch", "", "refs/heads/main", "1", "0"], + [ + "controller-selected PR", + "workflow_dispatch", + "a".repeat(40), + "refs/heads/candidate", + "0", + "1", + ], + [ + "pinned a4f9b59 diagnostic", + "workflow_dispatch", + "a4f9b59aa64f88532a3e64e949dd1b4068aa1f1e", + "refs/heads/candidate", + "0", + "1", + ], ])( "classifies %s without executing untrusted code (#7372)", - (_case, eventName, checkoutSha, required) => { + (_case, eventName, checkoutSha, ref, required, reuse) => { expect( runClassifier({ checkoutSha, eventName, - ref: "refs/heads/main", + ref, repository: "NVIDIA/NemoClaw", }), - ).toEqual({ output: `required=${required}\n`, status: 0 }); + ).toEqual({ output: `required=${required}\nreuse=${reuse}\n`, status: 0 }); }, ); @@ -203,7 +218,8 @@ describe("base-image publication workflow boundary (#7372)", () => { [ "contract validation", (value) => - (gateSteps(value)[5].run = "node tools/e2e/dcode-base-image-contract.mts contract.json"), + (gateStep(value, "Validate immutable Deep Agents Code base").run = + "node tools/e2e/dcode-base-image-contract.mts contract.json"), ], ["step count", (value) => gateSteps(value).push({ name: "Unreviewed step", run: "true" })], [ diff --git a/test/e2e/support/channels-add-remove-helpers.test.ts b/test/e2e/support/channels-add-remove-helpers.test.ts index 5a482e88994..eca016a6201 100644 --- a/test/e2e/support/channels-add-remove-helpers.test.ts +++ b/test/e2e/support/channels-add-remove-helpers.test.ts @@ -9,9 +9,11 @@ import { } from "../live/channels-add-remove-helpers.ts"; const UNCONFIGURED: OpenClawTelegramState = { + accountPresent: false, accountEnabled: false, channelEnabled: false, channelPresent: true, + credentialPresent: false, pluginEnabled: false, pluginPresent: true, }; @@ -21,30 +23,23 @@ describe("channels-add-remove Telegram configuration predicate", () => { expect(openClawHasConfiguredTelegram(UNCONFIGURED)).toBe(false); }); - it("detects an enabled channel and plugin as configured (#9361)", () => { - expect( - openClawHasConfiguredTelegram({ - ...UNCONFIGURED, - channelEnabled: true, - pluginEnabled: true, - }), - ).toBe(true); - }); - it.each([ ["enabled channel without plugin activation", { channelEnabled: true }], ["enabled plugin without channel activation", { pluginEnabled: true }], - ])("treats %s as unconfigured (#9361)", (_case, overrides) => { - expect(openClawHasConfiguredTelegram({ ...UNCONFIGURED, ...overrides })).toBe(false); + ["present account without enabled flags", { accountPresent: true }], + ["enabled account without enabled flags", { accountEnabled: true }], + ["credential reference without enabled flags", { credentialPresent: true }], + ])("treats %s as configured residue (#9361)", (_case, overrides) => { + expect(openClawHasConfiguredTelegram({ ...UNCONFIGURED, ...overrides })).toBe(true); }); - it("treats a removed channel with a bundled enabled plugin as unconfigured (#9361)", () => { + it("does not treat physical channel absence as proof when account residue remains (#9361)", () => { expect( openClawHasConfiguredTelegram({ ...UNCONFIGURED, channelPresent: false, - pluginEnabled: true, + accountPresent: true, }), - ).toBe(false); + ).toBe(true); }); }); diff --git a/test/e2e/support/corporate-ca-workload-kind.test.ts b/test/e2e/support/corporate-ca-workload-kind.test.ts new file mode 100644 index 00000000000..e2c922d890c --- /dev/null +++ b/test/e2e/support/corporate-ca-workload-kind.test.ts @@ -0,0 +1,124 @@ +// 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_REPOSITORIES, + MANAGED_IMAGE_STARTUP_PROFILE_CONTRACT_VERSION, +} 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 { registeredCorporateCaWorkloadKind } from "../fixtures/corporate-ca.ts"; + +const SANDBOX_NAME = "corporate-ca-authority"; +const GATEWAY_PORT = 7443; +const temporaryHomes: string[] = []; + +afterEach(() => { + for (const home of temporaryHomes.splice(0)) { + fs.rmSync(home, { force: true, recursive: true }); + } +}); + +function writeRegistry(entry: Record | null): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-corporate-ca-authority-")); + temporaryHomes.push(home); + const stateRoot = nemoclawStateRoot(home, GATEWAY_PORT); + fs.mkdirSync(stateRoot, { recursive: true }); + fs.writeFileSync( + path.join(stateRoot, "sandboxes.json"), + `${JSON.stringify({ sandboxes: entry === null ? {} : { [SANDBOX_NAME]: entry } })}\n`, + "utf8", + ); + return home; +} + +function managedRegistryEntry(): Record { + const encodedProfile = encodeManagedStartupProfile(managedStartupE2eProfile("openclaw")); + const reference = `${MANAGED_IMAGE_REPOSITORIES.openclaw}@sha256:${"a".repeat(64)}`; + return { + name: SANDBOX_NAME, + agent: "openclaw", + fromDockerfile: null, + imageTag: reference, + workload: { + schemaVersion: 1, + kind: "managed-image", + reference, + platform: "linux/amd64", + release: "v0.0.100", + sourceRevision: "d".repeat(40), + sourceCohort: "ghrun-9357-1", + 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, + }, + }; +} + +describe("corporate CA registered workload selection", () => { + it("selects managed assertions only from validated managed workload authority", () => { + const home = writeRegistry(managedRegistryEntry()); + + expect(registeredCorporateCaWorkloadKind(SANDBOX_NAME, home, GATEWAY_PORT)).toBe( + "managed-image", + ); + }); + + it("preserves the registered legacy Dockerfile assertion path", () => { + const home = writeRegistry({ + name: SANDBOX_NAME, + agent: null, + fromDockerfile: "/tmp/Dockerfile", + imageTag: "corporate-ca-legacy:local", + workload: { + schemaVersion: 1, + kind: "legacy-dockerfile", + reference: "corporate-ca-legacy:local", + shared: false, + }, + }); + + expect(registeredCorporateCaWorkloadKind(SANDBOX_NAME, home, GATEWAY_PORT)).toBe( + "legacy-dockerfile", + ); + }); + + it("fails closed when the sandbox is missing from the selected registry", () => { + const home = writeRegistry(null); + + expect(() => registeredCorporateCaWorkloadKind(SANDBOX_NAME, home, GATEWAY_PORT)).toThrow( + /missing from the registry/u, + ); + }); + + it("fails closed on a malformed registered workload receipt", () => { + const home = writeRegistry({ + name: SANDBOX_NAME, + agent: null, + fromDockerfile: "/tmp/Dockerfile", + imageTag: "corporate-ca-legacy:local", + workload: { + schemaVersion: 1, + kind: "legacy-dockerfile", + reference: "corporate-ca-legacy:local", + shared: true, + }, + }); + + expect(() => registeredCorporateCaWorkloadKind(SANDBOX_NAME, home, GATEWAY_PORT)).toThrow( + /no supported registered workload authority/u, + ); + }); +}); diff --git a/test/e2e/support/dashboard-connect-handoff.test.ts b/test/e2e/support/dashboard-connect-handoff.test.ts new file mode 100644 index 00000000000..f259301ec46 --- /dev/null +++ b/test/e2e/support/dashboard-connect-handoff.test.ts @@ -0,0 +1,185 @@ +// 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 { expect, test } from "../fixtures/e2e-test.ts"; +import { runDashboardConnectUntilForwardHandoff } from "../live/dashboard-connect-handoff.ts"; + +const SANDBOX_NAME = "e2e-dashboard-bind"; +const DASHBOARD_PORT = "18789"; + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitForProcessExit(pid: number): Promise { + const deadline = Date.now() + 2_000; + while (processExists(pid) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +async function stopFixtureProcess(pid: number): Promise { + try { + process.kill(pid, "SIGTERM"); + } catch { + // The forward may have already exited. + } + await waitForProcessExit(pid); + expect(processExists(pid)).toBe(false); +} + +test("accepts a normally completed connect when the forward is already healthy", async ({ + artifacts, + progress, +}) => { + const result = await runDashboardConnectUntilForwardHandoff({ + artifacts, + command: [process.execPath, "-e", "process.exit(0)"], + dashboardPort: DASHBOARD_PORT, + env: process.env, + progress, + sandboxName: SANDBOX_NAME, + timeoutMs: 2_000, + }); + + expect(result).toMatchObject({ exitCode: 0, proof: "command-completed", signal: null }); +}); + +test("rejects invalid handoff budgets before spawning connect", async ({ artifacts, progress }) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-handoff-budget-")); + const marker = path.join(directory, "spawned"); + const base = { + artifacts, + command: [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync(process.argv[1], "1")', + marker, + ] as const, + dashboardPort: DASHBOARD_PORT, + env: process.env, + progress, + sandboxName: SANDBOX_NAME, + }; + + try { + await expect(runDashboardConnectUntilForwardHandoff({ ...base, timeoutMs: 0 })).rejects.toThrow( + /timeout must be a positive finite value/, + ); + await expect( + runDashboardConnectUntilForwardHandoff({ + ...base, + stopGraceMs: Number.POSITIVE_INFINITY, + timeoutMs: 2_000, + }), + ).rejects.toThrow(/stop grace must be a positive finite value/); + expect(fs.existsSync(marker)).toBe(false); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +test("reaps interactive connect after missing-forward proof while its detached forward survives", async ({ + artifacts, + progress, +}) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-connect-handoff-")); + const pidFile = path.join(directory, "forward.pid"); + let forwardPid = Number.NaN; + try { + const script = [ + 'const fs = require("node:fs");', + 'const { spawn } = require("node:child_process");', + 'const forward = spawn(process.execPath, ["-e", "setInterval(() => undefined, 1000)"], { detached: true, stdio: "ignore" });', + "forward.unref();", + "try { fs.writeFileSync(process.argv[1], String(forward.pid)); } catch (error) { forward.kill('SIGTERM'); throw error; }", + `process.stdout.write(${JSON.stringify( + `Dashboard port forward to '${SANDBOX_NAME}' is missing or dead.\nRe-establishing...\n\u001B[32m✓\u001B[0m Dashboard port forward re-established.\n`, + )});`, + "process.on('SIGTERM', () => process.exit(0));", + "setInterval(() => undefined, 1000);", + ].join("\n"); + const result = await runDashboardConnectUntilForwardHandoff({ + artifacts, + command: [process.execPath, "-e", script, pidFile], + dashboardPort: DASHBOARD_PORT, + env: process.env, + progress, + sandboxName: SANDBOX_NAME, + timeoutMs: 2_000, + }); + + forwardPid = Number(fs.readFileSync(pidFile, "utf8")); + expect(result.proof).toBe("forward-started"); + expect(result.stdout).toContain("Dashboard port forward re-established."); + expect(processExists(forwardPid)).toBe(true); + } finally { + const cleanupPid = Number.isInteger(forwardPid) + ? forwardPid + : Number(fs.existsSync(pidFile) ? fs.readFileSync(pidFile, "utf8") : Number.NaN); + try { + expect( + Number.isInteger(cleanupPid) && cleanupPid > 0, + "fixture forward PID is unavailable; detached cleanup cannot be proven", + ).toBe(true); + await stopFixtureProcess(cleanupPid); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + } +}); + +test("fails when an attached descendant retains captured stdio after forward proof", async ({ + artifacts, + progress, +}) => { + const script = [ + 'const { spawn } = require("node:child_process");', + 'spawn(process.execPath, ["-e", "setInterval(() => undefined, 1000)"], { stdio: "inherit" });', + `process.stdout.write(${JSON.stringify( + "\u001B[32m✓\u001B[0m Dashboard port forward re-established.\n", + )});`, + "process.on('SIGTERM', () => process.exit(0));", + "setInterval(() => undefined, 1000);", + ].join("\n"); + + await expect( + runDashboardConnectUntilForwardHandoff({ + artifacts, + command: [process.execPath, "-e", script], + dashboardPort: DASHBOARD_PORT, + env: process.env, + progress, + sandboxName: SANDBOX_NAME, + stopGraceMs: 100, + timeoutMs: 2_000, + }), + ).rejects.toThrow(/retained captured descriptors/); +}); + +test("fails within budget and reaps a connect process that never proves handoff", async ({ + artifacts, + progress, +}) => { + await expect( + runDashboardConnectUntilForwardHandoff({ + artifacts, + command: [process.execPath, "-e", "setInterval(() => undefined, 1000)"], + dashboardPort: DASHBOARD_PORT, + env: process.env, + progress, + sandboxName: SANDBOX_NAME, + stopGraceMs: 100, + timeoutMs: 100, + }), + ).rejects.toThrow(/did not complete or prove forward handoff within budget/); +}); diff --git a/test/e2e/support/dashboard-remote-bind-env.test.ts b/test/e2e/support/dashboard-remote-bind-env.test.ts index f319b2235ee..cd99747a8be 100644 --- a/test/e2e/support/dashboard-remote-bind-env.test.ts +++ b/test/e2e/support/dashboard-remote-bind-env.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { buildDashboardRemoteBindEnv, + dashboardForwardIsRunning, dashboardRemoteBindConnectStarted, } from "../live/dashboard-remote-bind-env.ts"; @@ -32,7 +33,7 @@ describe("dashboard remote-bind E2E environment", () => { expect(env.NEMOCLAW_DASHBOARD_BIND).toBe("0.0.0.0"); }); - it("accepts recovery proof when connect has no numeric exit code", () => { + it("accepts recovery proof while connect remains interactive", () => { expect( dashboardRemoteBindConnectStarted( { @@ -73,6 +74,15 @@ describe("dashboard remote-bind E2E environment", () => { ).toBe(false); }); + it.each([ + ["e2e-dashboard-bind 0.0.0.0 18789 4242 running", true], + ["e2e-dashboard-bind 0.0.0.0 18789 4242 \u001B[32mrunning\u001B[39m", true], + ["e2e-dashboard-bind 0.0.0.0 18789 4242 not running", false], + ["e2e-dashboard-bind 0.0.0.0 18789 4242 stopped", false], + ])("recognizes only the exact running forward status: %s", (forwardLine, expected) => { + expect(dashboardForwardIsRunning(forwardLine)).toBe(expected); + }); + it("rejects a completed nonzero connect even when it printed recovery proof (#9606)", () => { expect( dashboardRemoteBindConnectStarted( diff --git a/test/e2e/support/dcode-base-image-runtime-evidence.test.ts b/test/e2e/support/dcode-base-image-runtime-evidence.test.ts index 52453f29443..f781a1cdfdf 100644 --- a/test/e2e/support/dcode-base-image-runtime-evidence.test.ts +++ b/test/e2e/support/dcode-base-image-runtime-evidence.test.ts @@ -58,6 +58,7 @@ function resolutionMetadata( ref: DCODE_BASE_IMAGE_AMD64_REFERENCE, digest: DCODE_BASE_IMAGE_AMD64_DIGEST, source: "override", + sourceRevision: DCODE_BASE_IMAGE_SOURCE_REVISION, imageId: `sha256:${"e".repeat(64)}`, os: "linux", architecture: "amd64", @@ -305,6 +306,24 @@ describe("Deep Agents Code published base runtime evidence", () => { expectedMessage: baseContractMismatch("pinned reference"), rejectedValues: [DCODE_BASE_IMAGE_AMD64_REFERENCE], }, + { + label: "a missing source revision", + metadata: resolutionMetadata({ sourceRevision: undefined }), + expectedMessage: baseContractMismatch("source revision"), + rejectedValues: [], + }, + { + label: "a malformed source revision", + metadata: resolutionMetadata({ sourceRevision: "main" }), + expectedMessage: baseContractMismatch("source revision"), + rejectedValues: ["main"], + }, + { + label: "a mismatched source revision", + metadata: resolutionMetadata({ sourceRevision: "f".repeat(40) }), + expectedMessage: baseContractMismatch("source revision"), + rejectedValues: ["f".repeat(40)], + }, { label: "an unsupported platform", metadata: resolutionMetadata({ architecture: "ppc64le" }), diff --git a/test/e2e/support/e2e-operations-workflow-boundary.test.ts b/test/e2e/support/e2e-operations-workflow-boundary.test.ts index fd52bdf640b..06043666d70 100644 --- a/test/e2e/support/e2e-operations-workflow-boundary.test.ts +++ b/test/e2e/support/e2e-operations-workflow-boundary.test.ts @@ -533,14 +533,6 @@ const interpolatedNeeds = \${{ toJSON ( needs ) }}; "c", "::error::checkout_repository must be an owner/repository name\n", ], - [ - "a PR commit mismatch", - "NVIDIA/NemoClaw", - "d", - "b", - "c", - "::error::checkout_sha must match the latest PR commit SHA\n", - ], [ "a PR base commit mismatch", "NVIDIA/NemoClaw", diff --git a/test/e2e/support/gateway-guard-legacy-keepalive-fixture.test.ts b/test/e2e/support/gateway-guard-legacy-keepalive-fixture.test.ts index 0d28946540c..98b7d89e692 100644 --- a/test/e2e/support/gateway-guard-legacy-keepalive-fixture.test.ts +++ b/test/e2e/support/gateway-guard-legacy-keepalive-fixture.test.ts @@ -237,21 +237,28 @@ describe("gateway guard legacy keepalive fixture", () => { record.Config.WorkingDir = "/sandbox"; }), }, + ])("rejects $name before legacy recreation (#9364)", ({ inspect }) => { + expect(() => rewriteManagedInspectForLegacyKeepalive(inspect, OLD_CONTAINER_ID)).toThrow( + "requires the reviewed OpenShell OCI workspace identity contract", + ); + }); + + it.each([ { - name: "a missing OpenShell management label without the OCI-user marker", + name: "a missing OpenShell management label", inspect: managedRuntimeInspectWithoutOciImageUser((record) => { delete record.Config.Labels; }), }, { - name: "a changed OpenShell management label without the OCI-user marker", + name: "a changed OpenShell management label", inspect: managedRuntimeInspectWithoutOciImageUser((record) => { record.Config.Labels = { "openshell.ai/managed-by": "unreviewed" }; }), }, ])("rejects $name before legacy recreation (#9364)", ({ inspect }) => { expect(() => rewriteManagedInspectForLegacyKeepalive(inspect, OLD_CONTAINER_ID)).toThrow( - "requires the reviewed OpenShell OCI workspace identity contract", + "requires the reviewed managed-image or OpenShell-managed runtime process contract", ); }); @@ -309,14 +316,6 @@ describe("gateway guard legacy keepalive fixture", () => { }); it.each([ - { - name: "an unreviewed OpenShell supervisor", - inspect: managedRuntimeInspect({ entrypoint: ["/unreviewed/openshell-sandbox"] }), - }, - { - name: "an unreviewed OpenShell supervisor command", - inspect: managedRuntimeInspect({ command: ["--workdir", "/unexpected"] }), - }, { name: "a missing managed startup command", inspect: managedRuntimeInspect({ environment: [] }), @@ -381,6 +380,23 @@ describe("gateway guard legacy keepalive fixture", () => { ); }); + it("canonicalizes the OpenShell-managed source independently of its inherited image process tuple (#9364)", () => { + const rewritten = JSON.parse( + rewriteManagedInspectForLegacyKeepalive( + managedRuntimeInspect({ + entrypoint: ["/image/entrypoint"], + command: ["/image/command"], + }), + OLD_CONTAINER_ID, + ), + ); + + expect(rewritten[0].Config).toMatchObject({ + Entrypoint: ["/opt/openshell/bin/openshell-sandbox"], + Cmd: ["--workdir", "/sandbox"], + }); + }); + it("rejects an unreviewed managed-image entrypoint before legacy recreation (#9364)", () => { expect(() => rewriteManagedInspectForLegacyKeepalive( diff --git a/test/e2e/support/hermes-gpu-startup-proof.test.ts b/test/e2e/support/hermes-gpu-startup-proof.test.ts new file mode 100644 index 00000000000..2062ba9bb18 --- /dev/null +++ b/test/e2e/support/hermes-gpu-startup-proof.test.ts @@ -0,0 +1,177 @@ +// 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 { ManagedWorkloadAuthority } from "../../../src/lib/onboard/workload/authority.ts"; +import { + assertHermesContainerImageAuthority, + assertHermesGpuStartupOutputContract, + assertHermesManagedWorkloadAuthority, + HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS, +} from "../live/hermes-gpu-startup-proof.ts"; + +const HEALTHY_NEW_GATEWAY = [ + "Starting OpenShell Docker-driver gateway...", + "Docker-driver gateway is healthy", +].join("\n"); +const NON_FALLBACK_DISCLOSURE_CASES = [ + ["native-success", HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS[0]], + ["native-success", HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS[1]], + ["native-success", HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS[2]], + ["native-success", HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS[3]], + ["native-success", HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS[4]], + ["compatibility-only", HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS[0]], + ["compatibility-only", HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS[1]], + ["compatibility-only", HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS[2]], + ["compatibility-only", HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS[3]], + ["compatibility-only", HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS[4]], +] as const; +const MANAGED_IMAGE_REFERENCE = `ghcr.io/nvidia/test@sha256:${"a".repeat(64)}`; +const OTHER_MANAGED_IMAGE_REFERENCE = `ghcr.io/nvidia/test@sha256:${"b".repeat(64)}`; +const VALID_MANAGED_AUTHORITY = { + agent: "hermes", + contract: { agent: "hermes", reference: MANAGED_IMAGE_REFERENCE }, + profile: { agent: "hermes" }, + receipt: { kind: "managed-image", reference: MANAGED_IMAGE_REFERENCE }, +} as unknown as ManagedWorkloadAuthority; + +describe("Hermes GPU startup output contract", () => { + it.each(["native-success", "compatibility-only"] as const)( + "accepts %s output without legacy Docker container progress text (#9362)", + (route) => { + expect(() => assertHermesGpuStartupOutputContract(route, HEALTHY_NEW_GATEWAY)).not.toThrow(); + }, + ); + + it("accepts fallback output only with the complete operator disclosure (#9362)", () => { + const output = [ + HEALTHY_NEW_GATEWAY, + "Operator-authorized GPU fallback enabled; trying native OpenShell injection with one compatibility retry.", + ...HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS, + ].join("\n"); + + expect(() => + assertHermesGpuStartupOutputContract("compatibility-fallback", output), + ).not.toThrow(); + }); + + it.each(HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS)( + "rejects fallback output that omits %s (#9362)", + (missingFragment) => { + const output = [ + HEALTHY_NEW_GATEWAY, + "Operator-authorized GPU fallback enabled; trying native OpenShell injection with one compatibility retry.", + ...HERMES_GPU_FALLBACK_DISCLOSURE_FRAGMENTS.filter( + (fragment) => fragment !== missingFragment, + ), + ].join("\n"); + + expect(() => + assertHermesGpuStartupOutputContract("compatibility-fallback", output), + ).toThrow(); + }, + ); + + it.each(NON_FALLBACK_DISCLOSURE_CASES)( + "rejects fallback disclosure in %s output: %s (#9362)", + (route, fragment) => { + expect(() => + assertHermesGpuStartupOutputContract(route, `${HEALTHY_NEW_GATEWAY}\n${fragment}`), + ).toThrow(); + }, + ); +}); + +describe("Hermes GPU managed-image authority proof", () => { + it("accepts one immutable authority shared by the registry, contract, and receipt (#9362)", () => { + expect( + assertHermesManagedWorkloadAuthority( + "hermes-gpu", + MANAGED_IMAGE_REFERENCE, + VALID_MANAGED_AUTHORITY, + ), + ).toBe(MANAGED_IMAGE_REFERENCE); + }); + + it("rejects a missing managed workload authority (#9362)", () => { + expect(() => + assertHermesManagedWorkloadAuthority("hermes-gpu", MANAGED_IMAGE_REFERENCE, null), + ).toThrow("has no managed workload authority"); + }); + + it.each([ + ["agent", { ...VALID_MANAGED_AUTHORITY, agent: "openclaw" }], + [ + "contract agent", + { + ...VALID_MANAGED_AUTHORITY, + contract: { ...VALID_MANAGED_AUTHORITY.contract, agent: "pi" }, + }, + ], + [ + "contract reference", + { + ...VALID_MANAGED_AUTHORITY, + contract: { + ...VALID_MANAGED_AUTHORITY.contract, + reference: "different-reference", + }, + }, + ], + ["profile agent", { ...VALID_MANAGED_AUTHORITY, profile: { agent: "openclaw" } }], + [ + "receipt kind", + { + ...VALID_MANAGED_AUTHORITY, + receipt: { ...VALID_MANAGED_AUTHORITY.receipt, kind: "custom" }, + }, + ], + ] as const)("rejects managed authority drift in %s (#9362)", (_label, authority) => { + expect(() => + assertHermesManagedWorkloadAuthority( + "hermes-gpu", + MANAGED_IMAGE_REFERENCE, + authority as unknown as ManagedWorkloadAuthority, + ), + ).toThrow(); + }); + + it("rejects registry-to-receipt image drift (#9362)", () => { + expect(() => + assertHermesManagedWorkloadAuthority( + "hermes-gpu", + OTHER_MANAGED_IMAGE_REFERENCE, + VALID_MANAGED_AUTHORITY, + ), + ).toThrow(); + }); + + it.each([ + "ghcr.io/nvidia/test:latest", + "ghcr.io/nvidia/test@sha256:different", + `ghcr.io/nvidia/test@sha256:${"A".repeat(64)}`, + ])("rejects matching mutable or malformed image authority: %s (#9362)", (reference) => { + const authority = { + ...VALID_MANAGED_AUTHORITY, + contract: { ...VALID_MANAGED_AUTHORITY.contract, reference }, + receipt: { ...VALID_MANAGED_AUTHORITY.receipt, reference }, + } as unknown as ManagedWorkloadAuthority; + + expect(() => assertHermesManagedWorkloadAuthority("hermes-gpu", reference, authority)).toThrow( + "has no immutable image reference", + ); + }); + + it("accepts the running container's exact digest-backed authority (#9362)", () => { + expect(() => + assertHermesContainerImageAuthority(MANAGED_IMAGE_REFERENCE, MANAGED_IMAGE_REFERENCE), + ).not.toThrow(); + }); + + it("rejects a running container outside the recorded authority (#9362)", () => { + expect(() => + assertHermesContainerImageAuthority("ghcr.io/nvidia/test:latest", MANAGED_IMAGE_REFERENCE), + ).toThrow(); + }); +}); diff --git a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts index 2026d73f6e1..6b0df9b13fc 100644 --- a/test/e2e/support/managed-image-protected-runtime-workflow.test.ts +++ b/test/e2e/support/managed-image-protected-runtime-workflow.test.ts @@ -41,6 +41,14 @@ function namedJobStep(value: WorkflowRecord, jobId: string, name: string): Recor return step as Record; } +function namedMultiarchStep(value: WorkflowRecord, name: string): Record { + const step = (multiarchJob(value).steps as Array>).find( + (step) => step.name === name, + ); + expect(step, `workflow step '${name}' is missing`).toBeDefined(); + return step as Record; +} + describe("protected managed-image runtime workflow", () => { it("accepts the checked-in protected runtime job", () => { expect(validateManagedImageProtectedRuntimeWorkflow(workflow())).toEqual([]); @@ -299,7 +307,17 @@ describe("protected managed-image runtime workflow", () => { runtimeJob(value).needs = ["generate-matrix"]; expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( - "managed-image-protected-runtime must depend on generate-matrix and managed-image-multiarch-startup", + "managed-image-protected-runtime must depend on base-image-publication, generate-matrix, and managed-image-multiarch-startup", + ); + }); + + it("rejects a mutable DCode base in protected runtime qualification", () => { + const value = workflow(); + const bases = namedStep(value, "Resolve exact amd64 runtime base images"); + bases.run = `${String(bases.run)}\nghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base:latest`; + + expect(validateManagedImageProtectedRuntimeWorkflow(value)).toContain( + "managed-image-protected-runtime must not resolve the DCode base from a mutable alias", ); }); @@ -363,6 +381,35 @@ describe("protected managed-image runtime workflow", () => { ); }); + it("requires the validated base publication before protected multiarch startup", () => { + const value = workflow(); + multiarchJob(value).needs = "generate-matrix"; + + expect(validateManagedImageMultiarchWorkflow(value)).toContain( + "managed-image-multiarch-startup must depend on base-image-publication and generate-matrix", + ); + }); + + it("selects each protected DCode base from the validated platform contract", () => { + const value = workflow(); + const bases = namedMultiarchStep(value, "Resolve exact platform base images"); + (bases.env as Record).DCODE_BASE_CONTRACT = "${{ inputs.base_contract }}"; + + expect(validateManagedImageMultiarchWorkflow(value)).toContain( + "managed-image-multiarch-startup exact base resolution must bind DCODE_BASE_CONTRACT to ${{ needs.base-image-publication.outputs.dcode_base_contract }}", + ); + }); + + it("rejects a mutable DCode base in protected multiarch startup", () => { + const value = workflow(); + const bases = namedMultiarchStep(value, "Resolve exact platform base images"); + bases.run = `${String(bases.run)}\nghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base:latest`; + + expect(validateManagedImageMultiarchWorkflow(value)).toContain( + "managed-image-multiarch-startup must not resolve the DCode base from a mutable alias", + ); + }); + it("requires one amd64 build-cache upload", () => { const value = workflow(); const job = multiarchJob(value); diff --git a/test/e2e/support/mcp-bridge-onboard-env.test.ts b/test/e2e/support/mcp-bridge-onboard-env.test.ts index 1aa599a1920..d11ebc60a41 100644 --- a/test/e2e/support/mcp-bridge-onboard-env.test.ts +++ b/test/e2e/support/mcp-bridge-onboard-env.test.ts @@ -108,6 +108,18 @@ describe("MCP bridge onboarding environment", () => { ]); }); + it("accepts the exact managed image 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: "a".repeat(40) }, + }), + ).not.toThrow(); + }); + it("passes only exact-main OpenShell overrides after fixed onboarding values", () => { const env = buildMcpBridgeOnboardEnv({ ...ONBOARD_OPTIONS, @@ -138,6 +150,30 @@ describe("MCP bridge onboarding environment", () => { expect(env.NEMOCLAW_CORPORATE_CA_BUNDLE).toBe("/tmp/nemoclaw-mcp-tls/ca.crt"); }); + it("passes exact PR managed-image catalog authority to onboarding commands (#8746)", () => { + const revision = "a".repeat(40); + const env = buildMcpBridgeOnboardEnv({ + ...ONBOARD_OPTIONS, + baseEnv: { + GITHUB_ACTIONS: "true", + GITHUB_WORKSPACE: "/test/workspace", + NEMOCLAW_E2E_EXPECTED_SHA: revision, + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: "/test/workspace/managed-pr-catalog.json", + NEMOCLAW_RUN_LIVE_E2E: "1", + NEMOCLAW_UNREVIEWED_WORKFLOW_INPUT: "must-not-pass", + }, + }); + + expect(env).toMatchObject({ + GITHUB_ACTIONS: "true", + GITHUB_WORKSPACE: "/test/workspace", + NEMOCLAW_E2E_EXPECTED_SHA: revision, + NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG: "/test/workspace/managed-pr-catalog.json", + NEMOCLAW_RUN_LIVE_E2E: "1", + }); + expect(env.NEMOCLAW_UNREVIEWED_WORKFLOW_INPUT).toBeUndefined(); + }); + it("requires the routed-private MCP test CA before onboarding", () => { expect(requireMcpBridgeTlsCaCert({ NEMOCLAW_MCP_TLS_CA_CERT: "/tmp/ca.crt" })).toBe( "/tmp/ca.crt", diff --git a/test/e2e/support/mcp-bridge-tool-discovery.test.ts b/test/e2e/support/mcp-bridge-tool-discovery.test.ts index 72d328dd6de..a62b30a7173 100644 --- a/test/e2e/support/mcp-bridge-tool-discovery.test.ts +++ b/test/e2e/support/mcp-bridge-tool-discovery.test.ts @@ -26,6 +26,7 @@ import { const EXPECTED_SECRET = "expected-secret"; const EXPECTED_RESULT_TOKEN = "expected-result"; const SESSION_ID = "fake-session-1"; +const LEGACY_SESSION_ID = "opaque-legacy-session"; const PROTOCOL_VERSION = "2025-03-26"; const STATUS_SECRET = "unregistered-sensitive-status-value"; @@ -53,6 +54,45 @@ function successfulInitialize(): FakeMcpRequest { }); } +function successfulLegacyDiscovery(): FakeMcpRequest[] { + return [ + { + method: "GET", + path: "/mcp", + auth: `Bearer ${EXPECTED_SECRET}`, + body: "", + sessionId: "", + protocolVersion: "", + responseStatus: 200, + negotiatedLegacySessionId: LEGACY_SESSION_ID, + legacyPhase: "opened", + }, + request("initialize", { + sessionId: "", + protocolVersion: "", + responseStatus: 202, + rpcId: 1, + legacySessionId: LEGACY_SESSION_ID, + negotiatedProtocolVersion: PROTOCOL_VERSION, + legacyPhase: "awaiting-initialized", + legacyResponseSequence: 1, + }), + request("notifications/initialized", { + sessionId: "", + legacySessionId: LEGACY_SESSION_ID, + legacyPhase: "ready", + }), + request("tools/list", { + sessionId: "", + responseStatus: 202, + rpcId: 2, + legacySessionId: LEGACY_SESSION_ID, + legacyPhase: "ready", + legacyResponseSequence: 2, + }), + ]; +} + interface CompatibleToolCall { id: string; function: { name: string; arguments: string }; @@ -151,6 +191,25 @@ describe("authenticated MCP rediscovery evidence", () => { ).toBe(true); }); + it("accepts legacy SSE discovery correlated to an authenticated event stream", () => { + expect( + hasSuccessfulAuthenticatedMcpDiscovery(successfulLegacyDiscovery(), EXPECTED_SECRET), + ).toBe(true); + }); + + it.each([ + ["an unauthenticated event stream", 0, { auth: "" }], + ["a missing event-stream correlation", 0, { negotiatedLegacySessionId: "" }], + ["a different POST endpoint", 3, { legacySessionId: "other-session" }], + ["a missing negotiated protocol header", 3, { protocolVersion: "" }], + ["a tools/list response without its JSON-RPC ID", 3, { rpcId: undefined }], + ])("rejects legacy SSE discovery with %s", (_failure, failedRequestIndex, override) => { + const requests = successfulLegacyDiscovery(); + Object.assign(requests[failedRequestIndex], override); + + expect(hasSuccessfulAuthenticatedMcpDiscovery(requests, EXPECTED_SECRET)).toBe(false); + }); + it("rejects tool discovery before session initialization completes", () => { expect( hasSuccessfulAuthenticatedMcpDiscovery( @@ -271,13 +330,19 @@ describe("authenticated MCP tool discovery transport retry", () => { { httpMethod: "POST", rpcMethod: "initialize", + transport: "streamable-http", responseStatus: 200, responseHasResult: true, + rpcIdPresent: false, + legacyPhase: null, + legacyResponseSequence: null, sessionMetadataPresent: { sessionId: false, protocolVersion: false, negotiatedSessionId: true, negotiatedProtocolVersion: true, + legacySessionId: false, + negotiatedLegacySessionId: false, }, credentialRewriteMatched: true, }, @@ -328,6 +393,14 @@ describe("authenticated MCP discovery restart retry", () => { expect(shouldRetryMcpDiscoveryAfterRestart([])).toBe(true); }); + it("retries when only a non-MCP credential readiness probe reached the fixture", () => { + expect( + shouldRetryMcpDiscoveryAfterRestart([ + { ...request("initialize"), rpcMethod: undefined }, + ]), + ).toBe(true); + }); + it("does not retry after the fixture received a request", () => { expect(shouldRetryMcpDiscoveryAfterRestart([request("initialize")])).toBe(false); }); diff --git a/test/e2e/support/workflow-plan.test.ts b/test/e2e/support/workflow-plan.test.ts index adf8c07fea8..e619ac62649 100644 --- a/test/e2e/support/workflow-plan.test.ts +++ b/test/e2e/support/workflow-plan.test.ts @@ -30,6 +30,7 @@ import { selectedWorkflowJobs, validateE2eWorkflowPlan, withoutCredentialedCatalogueProfiles, + withoutUnavailableOptionalCredentialTargets, writeE2eWorkflowPlanCiOutput, } from "../../../tools/e2e/workflow-plan.mts"; import { REPO_ROOT } from "../fixtures/paths.ts"; @@ -119,6 +120,18 @@ describe("E2E workflow plan", () => { expect(releaseRequiredWorkflowJobs()).not.toContain("llama-cpp-dgx-spark-qualification"); }); + it("omits only targets whose optional credential is unavailable", () => { + const plan = withoutUnavailableOptionalCredentialTargets(buildE2eWorkflowPlan(), new Set()); + const braveRows = plan.catalogueMatrices["brave-nvidia-inference"].map((row) => row.id); + + expect(braveRows).not.toContain("brave-search"); + expect(braveRows).not.toContain("common-egress-agent-openclaw-balanced-weather"); + expect(braveRows).toContain("common-egress-agent-openclaw-open-reference"); + expect(braveRows).toContain("common-egress-agent-hermes-open-reference"); + expect(plan.coverageMatrix.map((row) => row.id)).not.toContain("brave-search"); + expect(() => validateE2eWorkflowPlan(plan)).not.toThrow(); + }); + it("keeps multiple inert declarations visibly unresolved without treating them as evidence (#9167)", () => { const plan = buildE2eWorkflowPlan({ targets: "ubuntu-repo-cloud-hermes,ubuntu-repo-cloud-hermes-slack", diff --git a/test/generate-openclaw-config-plugin-entries.test.ts b/test/generate-openclaw-config-plugin-entries.test.ts index 346f509523f..cb9e44d3028 100644 --- a/test/generate-openclaw-config-plugin-entries.test.ts +++ b/test/generate-openclaw-config-plugin-entries.test.ts @@ -17,6 +17,13 @@ import { MANAGED_IMAGE_OPENCLAW_MESSAGING_CAPABILITIES, main, } from "../scripts/generate-openclaw-config.mts"; +import { applyMessagingAgentRenderToObject } from "../src/lib/messaging/applier/build/messaging-build-applier.mts"; +import { + createBuiltInChannelManifestRegistry, + createBuiltInRenderTemplateResolver, +} from "../src/lib/messaging/channels"; +import { MessagingWorkflowPlanner } from "../src/lib/messaging/compiler"; +import { createBuiltInMessagingHookRegistry } from "../src/lib/messaging/hooks"; import { baseOpenClawGenerationEnv } from "./helpers/openclaw-env-fixture"; const BASE_ENV = baseOpenClawGenerationEnv(); @@ -37,6 +44,35 @@ const EXPECTED_MANAGED_IMAGE_OPENCLAW_NEUTRAL_CAPABILITIES = [ ...EXPECTED_MANAGED_IMAGE_OPENCLAW_BUNDLED_INERT_CAPABILITIES, ] as const; +function messagingPlanner(): MessagingWorkflowPlanner { + return new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + createBuiltInMessagingHookRegistry({ + common: { + env: {}, + getCredential: (key) => + key === "TELEGRAM_BOT_TOKEN" ? "123456:test-telegram-token" : null, + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + }), + createBuiltInRenderTemplateResolver(), + ); +} + describe("generate-openclaw-config.mts: default plugin entries", () => { it("adds the installed NemoClaw plugin to the default OpenClaw allowlist (#8975)", () => { const config = buildConfig({ ...BASE_ENV }); @@ -89,6 +125,73 @@ describe("generate-openclaw-config.mts: default plugin entries", () => { expect(config.tools.web.search).toEqual({ enabled: false }); }); + it("removes active Telegram account and credential configuration while retaining its bundled inert capability (#9361)", async () => { + const baseline = buildConfig({ + ...BASE_ENV, + NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION: "1", + }); + expect(baseline.channels.telegram).toEqual({ enabled: false }); + expect(baseline.plugins.entries.telegram).toEqual({ enabled: false }); + + const planner = messagingPlanner(); + const addedPlan = await planner.buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + credentialAvailability: { TELEGRAM_BOT_TOKEN: true }, + }); + const added = structuredClone(baseline); + applyMessagingAgentRenderToObject(added, addedPlan, "openclaw.json"); + + expect(added.channels.telegram).toMatchObject({ + enabled: true, + accounts: { + default: { + botToken: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + enabled: true, + }, + }, + }); + expect(added.plugins.entries.telegram).toEqual({ enabled: true }); + expect(added.plugins.allow).toContain("telegram"); + expect(addedPlan.credentialBindings).toContainEqual( + expect.objectContaining({ channelId: "telegram", providerEnvKey: "TELEGRAM_BOT_TOKEN" }), + ); + expect(addedPlan.networkPolicy.entries.some((entry) => entry.channelId === "telegram")).toBe( + true, + ); + expect(JSON.stringify(added)).not.toContain("123456:test-telegram-token"); + + const removedPlan = await planner.buildChannelRemovePlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { schemaVersion: 1, plan: addedPlan }, + }, + channelId: "telegram", + }); + expect(removedPlan?.channels).toEqual([]); + expect(removedPlan?.credentialBindings).toEqual([]); + expect(removedPlan?.networkPolicy.entries).toEqual([]); + expect(removedPlan?.agentRender).toEqual([]); + + const removed = buildConfig({ + ...BASE_ENV, + NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION: "1", + }); + applyMessagingAgentRenderToObject(removed, removedPlan, "openclaw.json"); + expect(removed.channels.telegram).toEqual({ enabled: false }); + expect(removed.channels.telegram.accounts).toBeUndefined(); + expect(JSON.stringify(removed.channels.telegram)).not.toContain("TELEGRAM_BOT_TOKEN"); + expect(removed.plugins.entries.telegram).toEqual({ enabled: false }); + expect(removed.plugins.allow).not.toContain("telegram"); + expect(removed.channels.discord).toEqual({ enabled: false }); + expect(removed.plugins.entries.discord).toEqual({ enabled: false }); + }); + it("retains existing plugin allowlist and managed-image install metadata while explicitly disabling the plugin (#7744)", () => { const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-managed-union-")); const originalEnvironment = { ...process.env }; diff --git a/test/helpers/docker-state-mutation-harness.ts b/test/helpers/docker-state-mutation-harness.ts index be13ef0be52..81c7c476be4 100644 --- a/test/helpers/docker-state-mutation-harness.ts +++ b/test/helpers/docker-state-mutation-harness.ts @@ -144,9 +144,11 @@ export interface DockerStateMutationHarnessOptions { readonly deferAcquireOnce?: boolean; readonly failAcquire?: boolean; readonly failReleaseOnce?: boolean; + readonly failResumeOnce?: boolean; readonly lifecycleGeneration?: string; readonly loseAcquireResponseOnce?: boolean; readonly loseReleaseResponseOnce?: boolean; + readonly signalHelperOnce?: boolean; readonly stateMountType?: "bind" | "volume"; } @@ -160,6 +162,7 @@ export interface DockerStateMutationHarnessState { pidMode: string; privileged: boolean; overlayProc: boolean; + supervisorStopped: boolean; } function createContainerStateMutationHarness( @@ -182,16 +185,25 @@ function createContainerStateMutationHarness( pidMode: "", privileged: false, overlayProc: false, + supervisorStopped: false, }; const helperActions: string[] = []; + const supervisorSignals: string[] = []; const acquireRequests: string[] = []; + const transportCopySourceModes: number[] = []; let acquireDeferralsRemaining = options.deferAcquireOnce ? 1 : 0; let lostAcquireResponsesRemaining = options.loseAcquireResponseOnce ? 1 : 0; let releaseFailuresRemaining = options.failReleaseOnce ? 1 : 0; + let resumeFailuresRemaining = options.failResumeOnce ? 1 : 0; let lostReleaseResponsesRemaining = options.loseReleaseResponseOnce ? 1 : 0; + let signalledHelpersRemaining = options.signalHelperOnce ? 1 : 0; let marker: Record | null = null; let releasedMarker: Record | null = null; let deferredAcquireRequest: string | null = null; + let brokerActive = false; + let brokerReleased = false; + let brokerTransactionId: string | null = null; + const transportFiles = new Map(); const acquireMarker = (request: Record) => { const candidate = { @@ -282,14 +294,162 @@ function createContainerStateMutationHarness( stderr: "", }; } + if (command[0] === "container" && command[1] === "kill") { + if ( + command.length !== 5 || + command[2] !== "--signal" || + !["SIGSTOP", "SIGCONT"].includes(command[3] ?? "") || + command[4] !== DOCKER_STATE_MUTATION_RUNTIME_ID + ) { + return { status: 1, stdout: "", stderr: "unauthorized supervisor command" }; + } + const requestedSignal = command[3] as "SIGSTOP" | "SIGCONT"; + supervisorSignals.push(requestedSignal); + if (requestedSignal === "SIGCONT" && resumeFailuresRemaining > 0) { + resumeFailuresRemaining -= 1; + return { status: 1, stdout: "", stderr: "supervisor resume unavailable" }; + } + state.supervisorStopped = requestedSignal === "SIGSTOP"; + return { status: 0, stdout: `${DOCKER_STATE_MUTATION_RUNTIME_ID}\n`, stderr: "" }; + } + if (command[0] === "container" && command[1] === "cp") { + const source = command[2] ?? ""; + const destination = command[3] ?? ""; + const containerPrefix = `${DOCKER_STATE_MUTATION_RUNTIME_ID}:`; + if (destination.startsWith(containerPrefix)) { + const containerPath = destination.slice(containerPrefix.length); + const descriptor = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + let payload: Buffer; + try { + transportCopySourceModes.push(fs.fstatSync(descriptor).mode & 0o777); + payload = fs.readFileSync(descriptor); + } finally { + fs.closeSync(descriptor); + } + transportFiles.set(containerPath, payload); + if (containerPath.endsWith(".incoming")) { + const incoming = + /^([a-f0-9]{64})\.(acquire|assert|publish|recover|rollback|activate|release)\.incoming$/u.exec( + path.posix.basename(containerPath), + ); + if (incoming) { + const [, identity, action] = incoming; + const request = payload; + const envelope = JSON.parse(request.toString("utf8")) as { action?: string }; + if (envelope.action === action) { + transportFiles.delete(containerPath); + const helperTimeout = + action === "acquire" || action === "assert" + ? 30_000 + : action === "activate" || action === "release" + ? 5 * 60_000 + : 15 * 60_000; + let helperResult = capture( + "docker", + [ + "container", + "exec", + "--nemoclaw-broker", + DOCKER_STATE_MUTATION_RUNTIME_ID, + "/usr/local/lib/nemoclaw/runtime-state-mutation-control.py", + action, + ], + helperTimeout, + request, + ); + if (helperResult.status !== null && helperResult.status < 0) { + helperResult = capture( + "docker", + [ + "container", + "exec", + "--nemoclaw-broker", + DOCKER_STATE_MUTATION_RUNTIME_ID, + "/usr/local/lib/nemoclaw/runtime-state-mutation-control.py", + action, + ], + helperTimeout, + request, + ); + } + transportFiles.set( + `${path.posix.dirname(containerPath)}/${identity}.response`, + Buffer.from( + `${JSON.stringify({ + schemaVersion: 1, + action, + identity, + status: helperResult.status, + stdout: helperResult.stdout, + stderr: helperResult.stderr, + })}\n`, + "utf8", + ), + ); + } + } + } else if (containerPath.endsWith(".ack")) { + const base = containerPath.slice(0, -4); + const response = transportFiles.get(`${base}.response`); + if (response) { + const parsed = JSON.parse(response.toString("utf8")) as { + action?: string; + status?: number; + }; + if (parsed.action === "release" && parsed.status === 0) brokerReleased = true; + } + for (const suffix of [".response", ".ack"]) { + transportFiles.delete(`${base}${suffix}`); + } + } else if ( + containerPath.endsWith("/resumed") && + brokerReleased && + brokerTransactionId !== null && + payload.equals(Buffer.from(`${brokerTransactionId}\n`, "ascii")) + ) { + const session = path.posix.dirname(containerPath); + for (const file of [...transportFiles.keys()]) { + if (file === session || file.startsWith(`${session}/`)) transportFiles.delete(file); + } + brokerActive = false; + } + return { status: 0, stdout: "", stderr: "" }; + } + if (source.startsWith(containerPrefix)) { + const containerPath = source.slice(containerPrefix.length); + const payload = transportFiles.get(containerPath); + if (!payload) return { status: 1, stdout: "", stderr: "transport file unavailable" }; + fs.writeFileSync(destination, payload, { mode: 0o600 }); + return { status: 0, stdout: "", stderr: "" }; + } + return { status: 1, stdout: "", stderr: "unauthorized transport copy" }; + } if (command[0] !== "container" || command[1] !== "exec") { return { status: 1, stdout: "", stderr: "unexpected command" }; } + if (command[2] === "--detach") { + const transactionId = command.at(-1) ?? ""; + brokerActive = true; + brokerReleased = false; + brokerTransactionId = transactionId; + transportFiles.set( + `/run/nemoclaw/runtime-state-mutation/${transactionId}/ready`, + Buffer.from(`${transactionId}\n`, "ascii"), + ); + return { status: 0, stdout: "", stderr: "" }; + } + const brokerInvocation = command[2] === "--nemoclaw-broker"; + if (providerId === "docker" && state.supervisorStopped && !brokerInvocation) { + return { status: 1, stdout: "", stderr: "docker exec blocked after supervisor stop" }; + } const action = command.at(-1) ?? ""; helperActions.push(action); const serializedRequest = input?.toString("utf8") ?? "null"; const request = JSON.parse(serializedRequest) as Record; if (action === "acquire") { + if (!state.supervisorStopped) { + return { status: 1, stdout: "", stderr: "supervisor-not-host-stopped" }; + } acquireRequests.push(serializedRequest); if (options.failAcquire) { return { status: 1, stdout: "", stderr: "helper marker unavailable" }; @@ -374,6 +534,10 @@ function createContainerStateMutationHarness( } else if (releasedMarker === marker) { marker = null; } + if (signalledHelpersRemaining > 0) { + signalledHelpersRemaining -= 1; + return { status: -15, stdout: "", stderr: "" }; + } return { status: 0, stdout: `${JSON.stringify(response)}\n`, stderr: "" }; }); const root = temporaryRoot(); @@ -414,6 +578,7 @@ function createContainerStateMutationHarness( lifecycleGeneration, lifecycleLiveIdentityFingerprint: DOCKER_STATE_MUTATION_SANDBOX_FINGERPRINT, runtimeId: DOCKER_STATE_MUTATION_RUNTIME_ID, + hostTransportRoot: root, authority, engineAuthorityStore, lifecycleStore, @@ -456,6 +621,9 @@ function createContainerStateMutationHarness( context, engineAuthorityStore, helperActions, + supervisorSignals, + transportBrokerActive: () => brokerActive, + transportCopySourceModes, lifecycleStore, lifecycleGeneration, owner, diff --git a/test/helpers/managed-image-buildless-e2e.ts b/test/helpers/managed-image-buildless-e2e.ts index 462a758acec..32f2e8c105c 100644 --- a/test/helpers/managed-image-buildless-e2e.ts +++ b/test/helpers/managed-image-buildless-e2e.ts @@ -549,11 +549,11 @@ childProcess.spawn = (command, args = [], options = {}) => { }; const { loadAgent } = require(${source("src/lib/agent/defs.ts")}); -const { createSandboxWithTemporaryManagedRuntime } = require(${source("src/lib/onboard.ts")}); +const { createSandbox } = require(${source("src/lib/onboard.ts")}); (async () => { process.env.OPENSHELL_GATEWAY = "nemoclaw"; - await createSandboxWithTemporaryManagedRuntime( + await createSandbox( null, model, provider, diff --git a/test/helpers/managed-startup-root-replay-filesystem.ts b/test/helpers/managed-startup-root-replay-filesystem.ts index 603ec7046dc..17a7c83c7a4 100644 --- a/test/helpers/managed-startup-root-replay-filesystem.ts +++ b/test/helpers/managed-startup-root-replay-filesystem.ts @@ -62,21 +62,38 @@ export function mockRootReplayFilesystem( readonly afterRename: (callback: ((source: string, target: string) => void) | null) => void; readonly beforeLink: (callback: ((source: string, target: string) => void) | null) => void; readonly beforeUnlink: (callback: ((target: string) => void) | null) => void; + readonly chmodDirectory: (target: string, mode: number) => void; readonly hasFile: (target: string) => boolean; readonly linkCount: (target: string) => bigint; + readonly markDirectorySymlink: (target: string) => void; readonly readFile: (target: string) => string | null; readonly writeFile: (target: string, contents: string | Buffer, mode: number) => void; } { const directories = new Set([ "/", + "/etc", + "/etc/ssl", + "/etc/ssl/certs", "/run", "/run/nemoclaw", + "/usr", + "/usr/local", + "/usr/local/share", + "/usr/local/share/ca-certificates", + "/usr/sbin", "/var", "/var/lib", "/var/lib/nemoclaw", ]); + const fixtureFiles = new Map([ + [ + "/usr/sbin/update-ca-certificates", + { contents: "managed startup test executable", mode: 0o555 }, + ], + ...seededFiles, + ]); const files: Map = new Map( - [...seededFiles].map(([target, file]) => [ + [...fixtureFiles].map(([target, file]) => [ target, Buffer.isBuffer(file.contents) ? Buffer.from(file.contents) @@ -84,7 +101,8 @@ export function mockRootReplayFilesystem( ]), ); const directoryModes = new Map([...directories].map((target) => [target, 0o755])); - const fileModes = new Map([...seededFiles].map(([target, file]) => [target, file.mode])); + const symlinkDirectories = new Set(); + const fileModes = new Map([...fixtureFiles].map(([target, file]) => [target, file.mode])); let nextFileInode = 2n; const fileInodes = new Map(); const fileCtimes = new Map(); @@ -131,12 +149,12 @@ export function mockRootReplayFilesystem( descriptorSnapshots.set(descriptor, { ...snapshot, ctimeNs: nextCtime }); } }; - const stat = (kind: "directory" | "file", mode: number) => + const stat = (kind: "directory" | "file" | "symlink", mode: number) => ({ gid: 0, isDirectory: () => kind === "directory", isFile: () => kind === "file", - isSymbolicLink: () => false, + isSymbolicLink: () => kind === "symlink", mode, nlink: 1, uid: 0, @@ -202,7 +220,10 @@ export function mockRootReplayFilesystem( return directories.has(resolved) ? options?.bigint ? bigDirectoryStat(resolved) - : stat("directory", directoryModes.get(resolved) ?? 0o755) + : stat( + symlinkDirectories.has(resolved) ? "symlink" : "directory", + directoryModes.get(resolved) ?? 0o755, + ) : bytes === undefined ? missing() : options?.bigint @@ -411,8 +432,16 @@ export function mockRootReplayFilesystem( beforeUnlink: (callback) => { unlinkObserver = callback; }, + chmodDirectory: (target, mode) => { + if (!directories.has(target)) missing(); + directoryModes.set(target, mode); + }, hasFile: (target) => files.has(target), linkCount: (target) => fileLinkCount(fileInodes.get(target) ?? missing()), + markDirectorySymlink: (target) => { + if (!directories.has(target)) missing(); + symlinkDirectories.add(target); + }, readFile: (target) => files.get(target)?.toString("utf8") ?? null, writeFile: (target, contents, mode) => { if (files.has(target)) deleteExistingFile(target); diff --git a/test/helpers/onboard-script-mocks.cjs b/test/helpers/onboard-script-mocks.cjs index b970d7cc5ef..bdbae155aba 100644 --- a/test/helpers/onboard-script-mocks.cjs +++ b/test/helpers/onboard-script-mocks.cjs @@ -50,6 +50,122 @@ function normalizeCommand(command) { return (Array.isArray(command) ? command.join(" ") : String(command)).replace(/'/g, ""); } +function providerNameAfterAction(args, providerIndex) { + const firstArgument = providerIndex + 2; + return args[firstArgument] === "-g" ? args[firstArgument + 2] : args[firstArgument]; +} + +function createStatefulMessagingProviderRunner({ + commands, + initialProviders = [], + readySandboxName = null, +}) { + const providers = new Map( + initialProviders.map(([name, type, credential]) => [name, { type, credential }]), + ); + let lifecycleReleased = false; + return (command, options = {}) => { + const normalized = normalizeCommand(command); + const args = normalized.split(/\s+/); + const providerIndex = args.indexOf("provider"); + commands.push({ command: normalized, env: options.env || null }); + + const providerAction = providerIndex >= 0 ? args[providerIndex + 1] : null; + if (providerAction === "profile") { + const profileActionIndex = providerIndex + 2; + const profileAction = + args[profileActionIndex] === "-g" + ? args[profileActionIndex + 2] + : args[profileActionIndex]; + const fileIndex = args.indexOf("--file"); + return profileAction === "import" && fileIndex >= 0 && args[fileIndex + 1] + ? { status: 0 } + : { status: 1, stderr: "unsupported provider profile command" }; + } + if ( + args[providerIndex - 1] === "sandbox" && + (providerAction === "attach" || providerAction === "detach") + ) { + return args.length >= providerIndex + 4 + ? { status: 0 } + : { status: 1, stderr: `invalid provider ${providerAction} command` }; + } + if (providerAction === "create") { + const nameIndex = args.indexOf("--name"); + const typeIndex = args.indexOf("--type"); + const credentialIndex = args.indexOf("--credential"); + const name = nameIndex >= 0 ? args[nameIndex + 1] : null; + const type = typeIndex >= 0 ? args[typeIndex + 1] : null; + const credential = credentialIndex >= 0 ? args[credentialIndex + 1] : null; + if (!name || !type || !credential) { + return { status: 1, stderr: "invalid provider create command" }; + } + providers.set(name, { type, credential }); + return { status: 0 }; + } + if (providerAction === "get") { + const name = args.at(-1); + if (!name || name === "get") { + return { status: 1, stderr: "invalid provider get command" }; + } + const provider = providers.get(name); + return provider + ? { + status: 0, + stdout: [ + `Name: ${name}`, + `Type: ${provider.type}`, + `Credential keys: ${provider.credential}`, + "Config keys: ", + ].join("\n"), + } + : { status: 1, stderr: `provider '${name}' not found` }; + } + if (providerAction === "update") { + const name = providerNameAfterAction(args, providerIndex); + const credentialIndex = args.indexOf("--credential"); + const credential = credentialIndex >= 0 ? args[credentialIndex + 1] : null; + const provider = providers.get(name); + if (!name || !provider || (credentialIndex >= 0 && !credential)) { + return { status: 1, stderr: "invalid provider update command" }; + } + if (credential) provider.credential = credential; + return { status: 0 }; + } + if (providerAction === "delete") { + const name = providerNameAfterAction(args, providerIndex); + if (!name || !providers.delete(name)) { + return { status: 1, stderr: "invalid provider delete command" }; + } + return { status: 0 }; + } + if (providerIndex >= 0) { + return { status: 1, stderr: "unsupported provider command" }; + } + if (normalized.startsWith("docker rm ")) lifecycleReleased = true; + if (lifecycleReleased && args.includes("sandbox") && args.includes("list")) { + return { + status: 0, + stdout: Buffer.from("No sandboxes found\n"), + stderr: Buffer.alloc(0), + }; + } + if ( + readySandboxName && + args.includes("sandbox") && + args.includes("get") && + args.includes(readySandboxName) + ) { + return { + status: 0, + stdout: Buffer.from(`Name: ${readySandboxName}\nId: sbx-4f2a91c0d7\n`), + stderr: Buffer.alloc(0), + }; + } + return { status: 0 }; + }; +} + const OPENCLAW_SECURITY_INVENTORY_PROBE_PREFIX = Object.freeze([ "run", "--rm", @@ -214,6 +330,24 @@ function mockStandaloneGatewayTeardownAuthority() { }); } +function mockDockerSandboxLifecycleReleaseFromRunner() { + const runner = require(path.resolve(__dirname, "../../src/lib/runner.ts")); + const run = runner.run; + let lifecycleReleased = false; + runner.run = (command, options) => { + const normalized = normalizeCommand(command); + if (normalized.startsWith("docker rm ")) lifecycleReleased = true; + if (lifecycleReleased && normalized.includes("sandbox list")) { + return { + status: 0, + stdout: Buffer.from("No sandboxes found\n"), + stderr: Buffer.alloc(0), + }; + } + return run(command, options); + }; +} + function mockManagedImageFallback() { const catalog = require( path.resolve(__dirname, "../../src/lib/onboard/managed-image/catalog.ts"), @@ -246,7 +380,9 @@ function mockManagedImageFallback() { process.env.NEMOCLAW_TEST_MANAGED_IMAGE_FALLBACK === "1" && mockManagedImageFallback(); module.exports = { + createStatefulMessagingProviderRunner, isOpenClawSecurityInventoryProbe, + mockDockerSandboxLifecycleReleaseFromRunner, mockManagedImageFallback, mockOnboardRunCapture, mockSandboxExecCurl, diff --git a/test/hermes-mcp-startup-probe.test.ts b/test/hermes-mcp-startup-probe.test.ts index 851b9e6cd17..dec5f2b4b43 100644 --- a/test/hermes-mcp-startup-probe.test.ts +++ b/test/hermes-mcp-startup-probe.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ executeGatewaySupervisorAction: vi.fn(), isShieldsDown: vi.fn(), runOpenshellProviderCommand: vi.fn(), + sleepMs: vi.fn(), waitUntil: vi.fn(), })); @@ -20,6 +21,7 @@ vi.mock("../src/lib/actions/sandbox/process-recovery", () => ({ })); vi.mock("../src/lib/core/wait", () => ({ + sleepMs: mocks.sleepMs, waitUntil: mocks.waitUntil, })); diff --git a/test/install-forward-restore-diagnostics.test.ts b/test/install-forward-restore-diagnostics.test.ts index aeb28dbc214..d5164d38b1a 100644 --- a/test/install-forward-restore-diagnostics.test.ts +++ b/test/install-forward-restore-diagnostics.test.ts @@ -275,7 +275,7 @@ exit 0 }); }); -describe("Hermes host forward watcher", () => { +describe("Hermes host forward watcher", { timeout: 10_000 }, () => { it.each(["running", "active"])( "does not replace a forward that OpenShell lists as %s when the health check fails (#8884)", (status) => { diff --git a/test/langchain-deepagents-code-proxy-launcher.test.ts b/test/langchain-deepagents-code-proxy-launcher.test.ts index 8b818d49048..531aee86e7e 100644 --- a/test/langchain-deepagents-code-proxy-launcher.test.ts +++ b/test/langchain-deepagents-code-proxy-launcher.test.ts @@ -123,6 +123,50 @@ function shellValidatorAccepts(source: string, name: string, value: string): boo } describe("Deep Agents Code direct-exec proxy launcher", () => { + it("uses the live OpenShell CA bundle before the pre-resume fallback (#9360)", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-live-ca-")); + try { + const liveCaFile = path.join(tempDir, "openshell-live-ca.pem"); + const fallbackCaFile = path.join(tempDir, "managed-startup-ca.pem"); + fs.writeFileSync(liveCaFile, "live OpenShell CA\n", { mode: 0o444 }); + fs.writeFileSync(fallbackCaFile, "pre-resume fallback CA\n", { mode: 0o444 }); + const { envFile, scriptPath } = makeStartScriptFixture(tempDir, { + liveCaFile, + fallbackCaFile, + }); + + const liveResult = spawnSync("bash", [scriptPath, "true"], { + env: { + PATH: DEFAULT_TEST_PATH, + SSL_CERT_FILE: "/ambient-live-ca.pem", + REQUESTS_CA_BUNDLE: "/ambient-live-ca.pem", + NODE_EXTRA_CA_CERTS: "/ambient-live-ca.pem", + }, + encoding: "utf8", + }); + expect(liveResult.status, liveResult.stderr).toBe(0); + const liveEnvironment = fs.readFileSync(envFile, "utf8"); + expect(liveEnvironment).toContain(`_nemoclaw_dcode_ca_bundle=${liveCaFile}`); + expect(liveEnvironment).toContain("export SSL_CERT_FILE=/ambient-live-ca.pem"); + expect(liveEnvironment).toContain("export REQUESTS_CA_BUNDLE=/ambient-live-ca.pem"); + expect(liveEnvironment).toContain("export NODE_EXTRA_CA_CERTS=/ambient-live-ca.pem"); + + fs.rmSync(liveCaFile); + const fallbackResult = spawnSync("bash", [scriptPath, "true"], { + env: { PATH: DEFAULT_TEST_PATH }, + encoding: "utf8", + }); + expect(fallbackResult.status, fallbackResult.stderr).toBe(0); + const fallbackEnvironment = fs.readFileSync(envFile, "utf8"); + expect(fallbackEnvironment).toContain(`_nemoclaw_dcode_ca_bundle=${fallbackCaFile}`); + expect(fallbackEnvironment).toContain(`export SSL_CERT_FILE=${fallbackCaFile}`); + expect(fallbackEnvironment).toContain(`export REQUESTS_CA_BUNDLE=${fallbackCaFile}`); + expect(fallbackEnvironment).toContain(`export NODE_EXTRA_CA_CERTS=${fallbackCaFile}`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("keeps read-only identity commands outside the session supervisor", () => { const launcher = readAgentFile("dcode-launcher.sh"); const directIdentity = diff --git a/test/managed-image-activation-command.test.ts b/test/managed-image-activation-command.test.ts new file mode 100644 index 00000000000..79dad95b2d7 --- /dev/null +++ b/test/managed-image-activation-command.test.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { managedActivationOnboardArgs } from "./e2e/live/managed-image-activation-e2e-helpers"; + +describe("managed image activation command", () => { + it("uses an exact stock catalog without enabling candidate activation", () => { + expect( + managedActivationOnboardArgs("/tmp/catalog.json", "openclaw", "managed-openclaw"), + ).toEqual([ + "onboard", + "--temp-managed-runtime-catalog", + "/tmp/catalog.json", + "--fresh", + "--recreate-sandbox", + "--non-interactive", + "--yes", + "--no-gpu", + "--agent", + "openclaw", + "--name", + "managed-openclaw", + ]); + }); +}); diff --git a/test/managed-image-pr-base-resolution.test.ts b/test/managed-image-pr-base-resolution.test.ts index f8d9b793408..1f60303f975 100644 --- a/test/managed-image-pr-base-resolution.test.ts +++ b/test/managed-image-pr-base-resolution.test.ts @@ -11,15 +11,26 @@ import YAML from "yaml"; const repoRoot = path.resolve(import.meta.dirname, ".."); -function resolverScript(): string { +type WorkflowStep = { name?: string; run?: string; with?: Record }; + +function workflowSteps(job: string): WorkflowStep[] { const workflow = YAML.parse( fs.readFileSync(path.join(repoRoot, ".github/workflows/managed-images.yaml"), "utf8"), - ) as { - jobs?: Record }>; - }; - const resolver = workflow.jobs?.["pr-build-and-entrypoint"]?.steps?.find( - ({ name }) => name === "Resolve exact linux/amd64 PR base", - )?.run; + ) as { jobs?: Record }; + return workflow.jobs?.[job]?.steps ?? []; +} + +function workflowStep(job: string, name: string): WorkflowStep { + return ( + workflowSteps(job).find((step) => step.name === name) ?? + (() => { + throw new Error(`${job} step is missing: ${name}`); + })() + ); +} + +function resolverScript(): string { + const resolver = workflowStep("pr-build-and-entrypoint", "Resolve exact linux/amd64 PR base").run; return ( resolver ?? (() => { @@ -28,6 +39,31 @@ function resolverScript(): string { ); } +it("keeps immutable DCode base metadata on exact PR and production images", () => { + const prJob = "pr-build-and-entrypoint"; + const productionJob = "build-and-validate"; + const prResolver = resolverScript(); + const prLocalBuild = workflowStep(prJob, "Build PR managed image from local base"); + const prRegistryBuild = workflowStep(prJob, "Build PR managed image from registry base"); + const prPublish = workflowStep(prJob, "Publish exact same-repository PR managed image by digest"); + const prValidate = workflowStep(prJob, "Validate exact PR managed image contract"); + const productionBase = workflowStep(productionJob, "Validate exact base image contract"); + const productionBuild = workflowStep(productionJob, "Build and push managed image by digest"); + const productionValidate = workflowStep( + productionJob, + "Validate exact managed image before promotion", + ); + + expect(prResolver).toContain("sourceRevision:$revision"); + expect(prLocalBuild.run).toContain("com.nvidia.nemoclaw.base-resolution=${RESOLUTION_LABEL}"); + expect(prRegistryBuild.with?.labels).toContain("com.nvidia.nemoclaw.base-resolution={0}"); + expect(prPublish.with?.labels).toContain("com.nvidia.nemoclaw.base-resolution={0}"); + expect(productionBuild.with?.labels).toContain("com.nvidia.nemoclaw.base-resolution={0}"); + expect(productionBase.run).toContain("sourceRevision:$revision"); + expect(prValidate.run).toContain("managed image lost base resolution metadata"); + expect(productionValidate.run).toContain("image lost base resolution metadata"); +}); + it("builds a changed PR base locally and fails closed on comparison errors", () => { const resolver = resolverScript(); const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-local-pr-base-")); @@ -87,6 +123,7 @@ exit 90 ); const environment = { ...process.env, + AGENT: "openclaw", BASE_ALIAS: "ghcr.io/nvidia/nemoclaw/sandbox-base:latest", BASE_DOCKERFILE: "Dockerfile.base", BASE_REPOSITORY: "ghcr.io/nvidia/nemoclaw/sandbox-base", diff --git a/test/managed-image-publication-workflow.test.ts b/test/managed-image-publication-workflow.test.ts index 495d633ebd4..aa81a87231b 100644 --- a/test/managed-image-publication-workflow.test.ts +++ b/test/managed-image-publication-workflow.test.ts @@ -316,6 +316,7 @@ describe("complete managed-image publication workflow", () => { const matrix = prBuilder.strategy?.matrix?.include ?? []; const steps = prBuilder.steps ?? []; const permissionDrift = step(prBuilder, "Reproduce reviewed discovery permission drift"); + const releaseIdentity = step(prBuilder, "Resolve managed image release identity"); const localBaseBuild = step(prBuilder, "Build PR managed image from local base"); const registryBaseBuild = step(prBuilder, "Build PR managed image from registry base"); const contract = step(prBuilder, "Validate exact PR managed image contract"); @@ -377,6 +378,11 @@ describe("complete managed-image publication workflow", () => { expect(prBuilder.permissions).toEqual({ contents: "read", packages: "write" }); expect(step(prBuilder, "Checkout").with?.["persist-credentials"]).toBe(false); expect(step(prBuilder, "Checkout").with?.ref).toBe("${{ github.event.pull_request.head.sha }}"); + expect(releaseIdentity.id).toBe("release"); + expect(releaseIdentity.run).toContain( + "git describe --tags --match 'v*' \"$CANDIDATE_SHA\"", + ); + expect(releaseIdentity.run).toContain("value=%s"); expect(step(prBuilder, "Set up Docker Buildx").id).toBe("buildx"); const matrixByAgent = new Map(matrix.map((entry) => [entry.agent, entry])); expect([...matrixByAgent.keys()].sort()).toEqual([ @@ -430,13 +436,22 @@ describe("complete managed-image publication workflow", () => { expect(localBuild).toContain("--platform linux/amd64"); expect(localBuild).toContain('--build-arg "BASE_IMAGE=${BASE_IMAGE}"'); expect(localBuild).toContain('--tag "$IMAGE_REFERENCE"'); + expect(localBuild).toContain('--label "org.opencontainers.image.version=${RELEASE}"'); + expect(localBaseBuild.env?.RELEASE).toBe("${{ steps.release.outputs.value }}"); expect(localBuild).not.toContain("docker buildx build"); expect(registryBaseBuild.with).toMatchObject({ platforms: "linux/amd64", load: true, push: false, }); + expect(registryBaseBuild.with?.labels).toContain( + "org.opencontainers.image.version=${{ steps.release.outputs.value }}", + ); + expect(contract.env?.RELEASE).toBe("${{ steps.release.outputs.value }}"); const contractSource = required(contract.run, "PR managed image contract is missing"); + expect(contractSource).toContain( + '.[0].Config.Labels["org.opencontainers.image.version"] == $release', + ); expect(contractSource).toContain( 'docker run --rm --platform "$PLATFORM" --entrypoint /bin/sh "$image_id"', ); @@ -574,6 +589,9 @@ describe("complete managed-image publication workflow", () => { expect(publish.with?.["build-args"]).toContain( "BASE_IMAGE=${{ steps.base.outputs.local == 'true' && 'nemoclaw-pr-base' || steps.base.outputs.ref }}", ); + expect(publish.with?.labels).toContain( + "org.opencontainers.image.version=${{ steps.release.outputs.value }}", + ); expect(publish.with?.tags).toBeUndefined(); expect(logout.if).toContain(sameRepository); expect(exportContract.if).toBe(sameRepository); @@ -584,6 +602,10 @@ describe("complete managed-image publication workflow", () => { expect(exportContractRun.indexOf("scripts/checks/pull-public-exact-digest.sh")).toBeLessThan( exportContractRun.indexOf('docker buildx imagetools inspect "$reference" --raw'), ); + expect(exportContract.env?.RELEASE).toBe("${{ steps.release.outputs.value }}"); + expect(exportContractRun).toContain("org.opencontainers.image.version"); + expect(exportContractRun).toContain('--arg release "$RELEASE"'); + expect(exportContractRun).not.toContain("git describe --tags"); expect(exportContractRun).toContain("revision: $revision"); expect(JSON.stringify(prBuilder).match(/secrets\.GITHUB_TOKEN/gu)).toHaveLength(1); expect(JSON.stringify(prBuilder)).not.toContain("github.token"); @@ -682,8 +704,6 @@ describe("complete managed-image publication workflow", () => { path.join(repoRoot, "test/e2e/live/managed-image-activation-e2e-helpers.ts"), "utf8", ); - - expect(source).toContain('"--temp-managed-runtime-catalog"'); expect(source).toContain("await host.nemoclaw("); expect(source).toContain("await lifecycle.restartGatewayRuntime("); expect(source).toContain("await runAgentTurn("); @@ -755,6 +775,7 @@ fi encoding: "utf8", env: { ...process.env, + AGENT: "openclaw", ALIAS_RAW: aliasRaw, BASE_ALIAS: "ghcr.io/nvidia/nemoclaw/sandbox-base:latest", BASE_DOCKERFILE: "Dockerfile.base", @@ -905,6 +926,7 @@ fi expect(action.uses, action.name).toMatch(fullShaAction); }); expect(step(publisher, "Checkout").with?.["persist-credentials"]).toBe(false); + expect(step(publisher, "Checkout").with?.["fetch-depth"]).toBe(0); const restoreBase = step(publisher, "Restore exact base image contract"); expect(restoreBase.run).toContain('base64 --decode > "$contract_root/contract.json"'); expect(restoreBase.env?.OPENCLAW_CONTRACT_BASE64).toBe( @@ -925,11 +947,15 @@ fi expect(noncanonicalBase.stderr).not.toContain("TR=="); const guard = step(publisher, "Validate production build args"); + const releaseIdentity = step(publisher, "Resolve managed image release identity"); const build = step(publisher, "Build and push managed image by digest"); const validate = step(publisher, "Validate exact managed image before promotion"); const evidence = step(publisher, "Capture exact managed image publication evidence"); const dependencies = step(publisher, "Install managed-image publication harness dependencies"); expect(steps.indexOf(guard)).toBeLessThan(steps.indexOf(build)); + expect(releaseIdentity.id).toBe("release"); + expect(releaseIdentity.run).toContain("git describe --tags --match 'v*' \"$GITHUB_SHA\""); + expect(releaseIdentity.run).toContain("managed image release identity does not match"); expect(guard.run).toContain('scripts/check-production-build-args.sh "${build_args[@]}"'); expect(build.uses).toBe("docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"); expect(build.with).toMatchObject({ @@ -944,6 +970,9 @@ fi expect(build.with?.push).toBeUndefined(); expect(build.with?.tags).toBeUndefined(); expect(build.with?.labels).toContain("org.opencontainers.image.revision=${{ github.sha }}"); + expect(build.with?.labels).toContain( + "org.opencontainers.image.version=${{ steps.release.outputs.value }}", + ); expect(build.with?.labels).toContain("io.nvidia.nemoclaw.managed-image.contract=1"); expect(build.with?.labels).toContain( "io.nvidia.nemoclaw.managed-image.cohort=${{ needs.publication-identity.outputs.cohort }}", @@ -978,6 +1007,9 @@ fi "retention-days": 1, }); const validation = required(validate.run, "managed image validation script is missing"); + expect(validate.env?.RELEASE).toBe("${{ steps.release.outputs.value }}"); + expect(validation).toContain('release_label="$('); + expect(validation).toContain('[ "$release_label" != "$RELEASE" ]'); expect(validation.match(/docker run/g)).toHaveLength(2); expect(validation).toContain("run-managed-image-direct-e2e.ts"); expect(validation).toContain("npx --no-install tsx"); diff --git a/test/mcp-add-crash-consistency.test.ts b/test/mcp-add-crash-consistency.test.ts index 656f5b65021..3438fdb738b 100644 --- a/test/mcp-add-crash-consistency.test.ts +++ b/test/mcp-add-crash-consistency.test.ts @@ -64,6 +64,7 @@ let credentialUpdatedThisProcess = false; let observedCredentialAbsentThisProcess = false; let credentialRepublishBeforeObservationCountThisProcess = 0; let credentialRepublishAfterAbsenceCountThisProcess = 0; +let credentialFreeRefreshBeforeObservationCountThisProcess = 0; let credentialFreeRefreshAfterAbsenceCountThisProcess = 0; const registry = require("./src/lib/state/registry.js"); @@ -142,6 +143,14 @@ providerCommands.runOpenshellProviderCommand = (args) => { credentialRepublishAfterAbsenceCountThisProcess += 1; fs.appendFileSync(marker("republish-after-observed-absence"), "republish\n", { mode: 0o600 }); } + if ( + crashAfter === "credential-projection-delayed-hostless" && + isCredentialFreeRefresh && + !observedCredentialAbsentThisProcess + ) { + credentialFreeRefreshBeforeObservationCountThisProcess += 1; + fs.appendFileSync(marker("refresh-before-observed-absence"), "refresh\n", { mode: 0o600 }); + } if ( crashAfter === "credential-projection-delayed-hostless" && isCredentialFreeRefresh && @@ -256,7 +265,7 @@ processRecovery.executeSandboxCommand = (_sandbox, command) => { if (command === "command -v mcporter") { return { status: 0, stdout: "/usr/local/bin/mcporter\n", stderr: "" }; } - if (command.includes("config' 'add")) { + if (command.includes("config' 'add") || command.includes('"config", "add"')) { mark("adapter"); if (crashAfter === "adapter") process.exit(86); return { status: 0, stdout: "", stderr: "" }; @@ -638,6 +647,7 @@ describe("MCP add crash consistency", () => { expect(resumed.status, `${resumed.stdout}\n${resumed.stderr}`).toBe(0); expect(`${resumed.stdout}\n${resumed.stderr}`).not.toContain("host-only-secret"); expect(fs.existsSync(path.join(home, "credential-observed-absent.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "refresh-before-observed-absence.marker"))).toBe(false); const credentialFreeRefreshCount = fs .readFileSync(path.join(home, "refresh-after-observed-absence.marker"), "utf8") .split("\n") @@ -680,6 +690,7 @@ describe("MCP add crash consistency", () => { expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); expect(fs.existsSync(path.join(home, "observation.marker"))).toBe(false); expect(fs.existsSync(path.join(home, "provider.marker"))).toBe(true); + expect(fs.existsSync(path.join(home, "updated.marker"))).toBe(true); expect(fs.existsSync(path.join(home, "attached.marker"))).toBe(true); expect(fs.existsSync(path.join(home, "adapter.marker"))).toBe(true); expect(readBridge(home).addState).toBeUndefined(); diff --git a/test/mcp-bridge-servers.test.ts b/test/mcp-bridge-servers.test.ts index 7044abc3479..8465db35e81 100644 --- a/test/mcp-bridge-servers.test.ts +++ b/test/mcp-bridge-servers.test.ts @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { execFileSync } from "node:child_process"; +import { once } from "node:events"; import fs from "node:fs"; +import type { IncomingMessage } from "node:http"; import https from "node:https"; import os from "node:os"; import path from "node:path"; @@ -69,6 +71,26 @@ const fixtureTls = { key: fs.readFileSync(path.join(tlsDir, "server.key")), }; +async function* readSseData(response: IncomingMessage): AsyncGenerator { + response.setEncoding("utf8"); + let buffer = ""; + for await (const chunk of response) { + buffer += String(chunk); + let eventBoundary = buffer.indexOf("\n\n"); + while (eventBoundary !== -1) { + const event = buffer.slice(0, eventBoundary); + buffer = buffer.slice(eventBoundary + 2); + const data = event + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice("data: ".length)) + .join("\n"); + yield data; + eventBoundary = buffer.indexOf("\n\n"); + } + } +} + afterAll(() => { fs.rmSync(tlsDir, { recursive: true, force: true }); }); @@ -288,7 +310,7 @@ describe("authenticated MCP live fixtures", () => { } }); - it("implements stateless Streamable HTTP and validates the tool challenge", async () => { + it("implements authenticated Streamable HTTP and legacy SSE", async () => { const secret = "fixture-secret"; const challenge = "fixture-challenge"; const resultToken = `MCP_AUTH_REWRITE_OK::${challenge}`; @@ -308,17 +330,19 @@ describe("authenticated MCP live fixtures", () => { const request = async ( method: string, body?: Record, + target = url, + extraHeaders: Record = {}, ): Promise<{ status: number; body: string; json(): unknown }> => await new Promise((resolve, reject) => { const encoded = body ? JSON.stringify(body) : ""; const req = https.request( - url, + target, { method, ca: fixtureTls.cert, headers: encoded - ? { ...headers, "content-length": Buffer.byteLength(encoded) } - : headers, + ? { ...headers, ...extraHeaders, "content-length": Buffer.byteLength(encoded) } + : { ...headers, ...extraHeaders }, }, (response) => { let responseBody = ""; @@ -352,12 +376,335 @@ describe("authenticated MCP live fixtures", () => { expect(initialize.json()).toMatchObject({ result: { protocolVersion: "2025-06-18" }, }); + const sessionId = server.requests.at(-1)?.negotiatedSessionId ?? ""; + expect(sessionId).toMatch(/^fake-session-\d+$/u); const initialized = await request("POST", { jsonrpc: "2.0", method: "notifications/initialized", }); expect(initialized.status).toBe(202); + const openEventChannel = async ( + eventHeaders: Record, + ): Promise => + await new Promise((resolve, reject) => { + const eventRequest = https.request( + url, + { + method: "GET", + ca: fixtureTls.cert, + headers: eventHeaders, + }, + resolve, + ); + eventRequest.on("error", reject); + eventRequest.end(); + }); + const missingCredential = await openEventChannel({ + authorization: "Bearer wrong-secret", + accept: "text/event-stream", + }); + expect(missingCredential.statusCode).toBe(401); + missingCredential.resume(); + const eventChannel = await openEventChannel({ + authorization: `Bearer ${secret}`, + accept: "text/event-stream", + "mcp-session-id": sessionId, + "mcp-protocol-version": "2025-06-18", + }); + expect(eventChannel.statusCode).toBe(200); + expect(eventChannel.headers["content-type"]).toBe("text/event-stream"); + eventChannel.setEncoding("utf8"); + const [firstEventChunk] = await once(eventChannel, "data", { + signal: AbortSignal.timeout(1_000), + }); + expect(firstEventChunk).toBe(": connected\n\n"); + expect(eventChannel.complete).toBe(false); + eventChannel.destroy(); + + const openLegacySession = async (): Promise<{ + channel: IncomingMessage; + endpoint: string; + reader: AsyncGenerator; + sessionId: string; + }> => { + const channel = await openEventChannel({ + authorization: `Bearer ${secret}`, + accept: "text/event-stream", + }); + expect(channel.statusCode).toBe(200); + const reader = readSseData(channel); + const endpointEvent = await reader.next(); + expect(endpointEvent.done).toBe(false); + const endpoint = new URL(endpointEvent.value ?? "", url); + const opaqueSessionId = endpoint.searchParams.get("legacySessionId") ?? ""; + expect(endpoint.pathname).toBe("/mcp"); + expect(opaqueSessionId).toMatch(/^[A-Za-z0-9_-]{43}$/u); + return { + channel, + endpoint: endpoint.href, + reader, + sessionId: opaqueSessionId, + }; + }; + const initializeLegacySession = async ( + legacy: Awaited>, + id: string | number, + ): Promise => { + const initializeEvent = legacy.reader.next(); + expect( + ( + await request( + "POST", + { + jsonrpc: "2.0", + id, + method: "initialize", + params: { protocolVersion: "2025-06-18" }, + }, + legacy.endpoint, + ) + ).status, + ).toBe(202); + expect(JSON.parse((await initializeEvent).value ?? "")).toMatchObject({ + id, + result: { protocolVersion: "2025-06-18" }, + }); + expect( + ( + await request( + "POST", + { jsonrpc: "2.0", method: "notifications/initialized" }, + legacy.endpoint, + { "mcp-protocol-version": "2025-06-18" }, + ) + ).status, + ).toBe(202); + }; + + const legacy = await openLegacySession(); + expect(server.activeLegacySessionCount()).toBe(1); + const guessedEndpoint = new URL(legacy.endpoint); + guessedEndpoint.searchParams.set("legacySessionId", "A".repeat(43)); + expect( + ( + await request( + "POST", + { jsonrpc: "2.0", id: 9, method: "tools/list" }, + guessedEndpoint.href, + { "mcp-protocol-version": "2025-06-18" }, + ) + ).status, + ).toBe(404); + expect( + ( + await request( + "POST", + { jsonrpc: "2.0", id: 9, method: "tools/list" }, + legacy.endpoint, + ) + ).status, + ).toBe(409); + expect( + ( + await request( + "POST", + { + jsonrpc: "2.0", + id: 9, + method: "initialize", + params: { protocolVersion: "2025-06-18" }, + }, + legacy.endpoint, + { "mcp-protocol-version": "2025-06-18" }, + ) + ).status, + ).toBe(400); + + const legacyInitializeEvent = legacy.reader.next(); + expect( + ( + await request( + "POST", + { + jsonrpc: "2.0", + id: 10, + method: "initialize", + params: { protocolVersion: "2025-06-18" }, + }, + legacy.endpoint, + ) + ).status, + ).toBe(202); + expect(JSON.parse((await legacyInitializeEvent).value ?? "")).toMatchObject({ + id: 10, + result: { protocolVersion: "2025-06-18" }, + }); + expect( + ( + await request( + "POST", + { + jsonrpc: "2.0", + id: 10, + method: "initialize", + params: { protocolVersion: "2025-06-18" }, + }, + legacy.endpoint, + ) + ).status, + ).toBe(409); + expect( + ( + await request( + "POST", + { jsonrpc: "2.0", method: "notifications/initialized" }, + legacy.endpoint, + ) + ).status, + ).toBe(400); + expect( + ( + await request( + "POST", + { jsonrpc: "2.0", method: "notifications/initialized" }, + legacy.endpoint, + { "mcp-protocol-version": "2025-03-26" }, + ) + ).status, + ).toBe(400); + expect( + ( + await request( + "POST", + { jsonrpc: "2.0", method: "notifications/initialized" }, + legacy.endpoint, + { "mcp-protocol-version": "2025-06-18" }, + ) + ).status, + ).toBe(202); + expect( + ( + await request( + "POST", + { jsonrpc: "2.0", id: 11, method: "tools/list" }, + legacy.endpoint, + ) + ).status, + ).toBe(400); + expect( + ( + await request( + "POST", + { jsonrpc: "2.0", id: 11, method: "tools/list" }, + legacy.endpoint, + { "mcp-protocol-version": "2025-03-26" }, + ) + ).status, + ).toBe(400); + expect( + ( + await request( + "POST", + { jsonrpc: "2.0", id: 11, method: "tools/list" }, + legacy.endpoint, + { + "mcp-protocol-version": "2025-06-18", + "mcp-session-id": "fake-session-cross-route", + }, + ) + ).status, + ).toBe(400); + const legacyListEvent = legacy.reader.next(); + expect( + ( + await request( + "POST", + { jsonrpc: "2.0", id: 11, method: "tools/list" }, + legacy.endpoint, + { "mcp-protocol-version": "2025-06-18" }, + ) + ).status, + ).toBe(202); + expect(JSON.parse((await legacyListEvent).value ?? "")).toMatchObject({ + id: 11, + result: { tools: [{ name: "fake_echo" }] }, + }); + + const secondLegacy = await openLegacySession(); + expect(secondLegacy.sessionId).not.toBe(legacy.sessionId); + await initializeLegacySession(secondLegacy, "second-init"); + expect(server.activeLegacySessionCount()).toBe(2); + + const firstStreamEvent = legacy.reader.next(); + const secondStreamEvent = secondLegacy.reader.next(); + expect( + await Promise.all([ + request( + "POST", + { jsonrpc: "2.0", id: "first-stream", method: "tools/list" }, + legacy.endpoint, + { "mcp-protocol-version": "2025-06-18" }, + ), + request( + "POST", + { jsonrpc: "2.0", id: "second-stream", method: "tools/list" }, + secondLegacy.endpoint, + { "mcp-protocol-version": "2025-06-18" }, + ), + ]).then((responses) => responses.map((response) => response.status)), + ).toEqual([202, 202]); + expect(JSON.parse((await firstStreamEvent).value ?? "")).toMatchObject({ id: "first-stream" }); + expect(JSON.parse((await secondStreamEvent).value ?? "")).toMatchObject({ + id: "second-stream", + }); + + const requestOffset = server.requests.length; + const orderedEvents = [legacy.reader.next(), legacy.reader.next()]; + const concurrentResponses = await Promise.all([ + request( + "POST", + { jsonrpc: "2.0", id: 30, method: "tools/list" }, + legacy.endpoint, + { "mcp-protocol-version": "2025-06-18" }, + ), + request( + "POST", + { jsonrpc: "2.0", id: 31, method: "tools/list" }, + legacy.endpoint, + { "mcp-protocol-version": "2025-06-18" }, + ), + ]); + expect(concurrentResponses.map((response) => response.status)).toEqual([202, 202]); + const wireIds = await Promise.all( + orderedEvents.map(async (event) => (JSON.parse((await event).value ?? "") as { id: number }).id), + ); + const recordedResponses = server.requests + .slice(requestOffset) + .filter((record) => record.legacySessionId === legacy.sessionId) + .sort( + (left, right) => + (left.legacyResponseSequence ?? Number.MAX_SAFE_INTEGER) - + (right.legacyResponseSequence ?? Number.MAX_SAFE_INTEGER), + ); + expect(recordedResponses.map((record) => record.rpcId)).toEqual(wireIds); + expect(recordedResponses.map((record) => record.legacyResponseSequence)).toEqual([4, 5]); + + legacy.channel.destroy(); + await expect.poll(() => server.activeLegacySessionCount()).toBe(1); + expect( + ( + await request( + "POST", + { jsonrpc: "2.0", id: 40, method: "tools/list" }, + legacy.endpoint, + { "mcp-protocol-version": "2025-06-18" }, + ) + ).status, + ).toBe(404); + secondLegacy.channel.destroy(); + await expect.poll(() => server.activeLegacySessionCount()).toBe(0); + const list = await request("POST", { jsonrpc: "2.0", id: 2, diff --git a/test/mcp-tool-discovery-image-contract.test.ts b/test/mcp-tool-discovery-image-contract.test.ts index f1f72032668..385e1475fd2 100644 --- a/test/mcp-tool-discovery-image-contract.test.ts +++ b/test/mcp-tool-discovery-image-contract.test.ts @@ -210,7 +210,7 @@ describe("MCP tool discovery image contract", () => { ); const expectedHashes = { "managed-startup-image-runtime.bundle": - "7c16aeeba1b1cd613878c7ebd706cf0af57519a8d49e778866a07d4972a3e602", + "296a54f8d7d2ff63ba82254d83797891bd18e7dc7724acd0f2d7deb92435d43a", "mcp-tool-discovery/BUNDLED_PACKAGES.json": "df5dc8f167101085a8e73c444aa56854b2a4716a0bb7de9886fec4e50f402601", "mcp-tool-discovery/THIRD_PARTY_LICENSES.txt": diff --git a/test/nemoclaw-start-plugin-refresh.test.ts b/test/nemoclaw-start-plugin-refresh.test.ts index ae15a2f5d77..cc16753fe4e 100644 --- a/test/nemoclaw-start-plugin-refresh.test.ts +++ b/test/nemoclaw-start-plugin-refresh.test.ts @@ -65,6 +65,7 @@ function runRefreshBlock( refreshLog: string; envLog: string; callLog: string; + hashRefreshState: string; preRefreshState: string; registryState: string; tmpDir: string; @@ -75,6 +76,7 @@ function runRefreshBlock( const callLog = path.join(tmpDir, "calls.log"); const envLog = path.join(tmpDir, "env.log"); const refreshLog = path.join(tmpDir, "refresh.txt"); + const hashRefreshState = path.join(tmpDir, "hash-refresh-state.txt"); const preRefreshState = path.join(tmpDir, "registry-state.pre.txt"); const registryState = path.join(tmpDir, "registry-state.txt"); const readyCounter = path.join(tmpDir, "ready-counter"); @@ -162,6 +164,7 @@ function runRefreshBlock( "GATEWAY_WATCHDOG_PID=", "GATEWAY_WATCHDOG_PID_START_IDENTITY=", 'gateway_control_pid_is_live() { case "$1" in ""|0|1|*[!0-9]*) return 1 ;; *) return 0 ;; esac; }', + `ensure_mutable_openclaw_config_hash() { cp ${JSON.stringify(registryState)} ${JSON.stringify(hashRefreshState)}; }`, block, "# Surface PLUGIN_REFRESH_PID + tracked SANDBOX_CHILD_PIDS for the test", 'printf "PLUGIN_REFRESH_PID=%s\\n" "$PLUGIN_REFRESH_PID"', @@ -179,7 +182,16 @@ function runRefreshBlock( env: { ...process.env, HOME: "/root", USER: "root" }, // adversarial: parent has wrong HOME }); - return { result, refreshLog, envLog, callLog, preRefreshState, registryState, tmpDir }; + return { + result, + refreshLog, + envLog, + callLog, + hashRefreshState, + preRefreshState, + registryState, + tmpDir, + }; } describe("plugin refresh log preparation", () => { @@ -352,11 +364,26 @@ describe("plugin registry refresh workaround for openclaw/openclaw#89606 (#2021) } }); + it("refreshes the mutable config hash after the registry mutation completes", () => { + const { result, hashRefreshState, registryState, tmpDir } = runRefreshBlock(); + try { + expect(result.status).toBe(0); + const hashedState = fs.readFileSync(hashRefreshState, "utf-8"); + expect(hashedState).toBe(fs.readFileSync(registryState, "utf-8")); + expect(hashedState).toContain("plugins:nemoclaw"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("skips the refresh when the gateway never reports ready", () => { - const { result, refreshLog, callLog, tmpDir } = runRefreshBlock({ gatewayReadyAfter: 99 }); + const { result, refreshLog, callLog, hashRefreshState, tmpDir } = runRefreshBlock({ + gatewayReadyAfter: 99, + }); try { expect(result.status).toBe(0); expect(fs.existsSync(refreshLog)).toBe(false); + expect(fs.existsSync(hashRefreshState)).toBe(false); const calls = fs.readFileSync(callLog, "utf-8"); const probeCount = calls.split("\n").filter((l) => l === "gateway status").length; expect(probeCount).toBe(10); diff --git a/test/onboard-custom-dockerfile.test.ts b/test/onboard-custom-dockerfile.test.ts index 6545d8b065a..abc2ba67e4c 100644 --- a/test/onboard-custom-dockerfile.test.ts +++ b/test/onboard-custom-dockerfile.test.ts @@ -235,6 +235,7 @@ runner.run = (command, opts = {}) => { ? { status: 0, stdout: Buffer.from("Name: my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; }; +require(${onboardScriptMocksPath}).mockDockerSandboxLifecycleReleaseFromRunner(); runner.runCapture = (command) => { if (_n(command).includes("sandbox get") && _n(command).includes("my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; @@ -312,9 +313,10 @@ const { createSandbox } = require(${onboardPath}); PATH: `${fakeBin}:${process.env.PATH || ""}`, NEMOCLAW_NON_INTERACTIVE: "1", }, + timeout: 30_000, }); - assert.equal(result.status, 0, result.stderr); + assert.equal(result.status, 0, result.stderr || result.error?.message); const payloadLine = result.stdout .trim() .split("\n") diff --git a/test/onboard-extra-provider-reconciliation.test.ts b/test/onboard-extra-provider-reconciliation.test.ts index 5f6767ac565..eb8e3e87697 100644 --- a/test/onboard-extra-provider-reconciliation.test.ts +++ b/test/onboard-extra-provider-reconciliation.test.ts @@ -78,6 +78,7 @@ runner.run = (command, opts = {}) => { ? { status: 0, stdout: Buffer.from("my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; }; +require(${onboardScriptMocksPath}).mockDockerSandboxLifecycleReleaseFromRunner(); runner.runCapture = (command) => { const normalized = _n(command); if (normalized.includes("sandbox get") && normalized.includes("my-assistant")) return ""; @@ -142,6 +143,7 @@ const { createSandbox } = require(${onboardPath}); const result = spawnSync(process.execPath, [scriptPath], { cwd: repoRoot, encoding: "utf-8", + timeout: 30_000, env: { ...process.env, HOME: tmpDir, @@ -153,7 +155,7 @@ const { createSandbox } = require(${onboardPath}); }, }); - assert.equal(result.status, 0, result.stderr); + assert.equal(result.status, 0, result.stderr || result.error?.message); const payloadLine = result.stdout .trim() .split("\n") diff --git a/test/onboard-inference-reconciliation.test.ts b/test/onboard-inference-reconciliation.test.ts index 06b7799fbc6..0954b163da9 100644 --- a/test/onboard-inference-reconciliation.test.ts +++ b/test/onboard-inference-reconciliation.test.ts @@ -197,6 +197,7 @@ describe("onboard helpers", () => { fs.mkdirSync(fakeBin, { recursive: true }); writeOkOpenshell(fakeBin); + fs.writeFileSync(path.join(fakeBin, "brew"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); const script = String.raw` const runner = require(${runnerPath}); @@ -233,6 +234,28 @@ preflight.assessHost = () => ({ const bridgeDnsPreflight = require(${bridgeDnsPreflightPath}); bridgeDnsPreflight.assertDockerBridgeAndContainerDnsHealthy = () => {}; const preflightGatewayAuthority = require(${preflightGatewayAuthorityPath}); +const createPreflightGatewayAuthority = + preflightGatewayAuthority.createOnboardPreflightGatewayAuthority; +preflightGatewayAuthority.createOnboardPreflightGatewayAuthority = (deps) => ({ + ...createPreflightGatewayAuthority(deps), + runRuntimePreflight: async () => ({ + gpu: null, + host: preflight.assessHost(), + readinessReport: {}, + sandboxGpuConfig: { + mode: "0", + hostGpuDetected: false, + hostGpuPlatform: null, + sandboxGpuEnabled: false, + sandboxGpuDevice: null, + errors: [], + }, + }), + prepareGatewayAuthority: async () => ({ + externallySupervised: false, + gatewayReuseState: "healthy", + }), +}); const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); const commands = []; diff --git a/test/onboard-installer-restore-intent.test.ts b/test/onboard-installer-restore-intent.test.ts index 073e7aafb4e..b2fc0d69ea2 100644 --- a/test/onboard-installer-restore-intent.test.ts +++ b/test/onboard-installer-restore-intent.test.ts @@ -73,6 +73,7 @@ runner.run = (command) => { ? { status: 0, stdout: Buffer.from("my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; }; +require(${onboardScriptMocksPath}).mockDockerSandboxLifecycleReleaseFromRunner(); runner.runCapture = (command) => { const cmd = _n(command); if (cmd.includes("sandbox get") && cmd.includes("my-assistant")) return sandboxDeleted && !sandboxRecreated ? "" : ["my-assistant", "Id: sbx-4f2a91c0d7"].join(String.fromCharCode(10)); @@ -197,9 +198,10 @@ const MARKER_SHA = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852 cwd: repoRoot, encoding: "utf-8", env, + timeout: 30_000, }); - assert.equal(result.status, 0, result.stderr); + assert.equal(result.status, 0, result.stderr || result.error?.message); const payloadLine = result.stdout .trim() .split("\n") diff --git a/test/onboard-managed-image-buildless-e2e.test.ts b/test/onboard-managed-image-buildless-e2e.test.ts index cfe3ad2eae9..6b16ceeae7e 100644 --- a/test/onboard-managed-image-buildless-e2e.test.ts +++ b/test/onboard-managed-image-buildless-e2e.test.ts @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 // @module-tag e2e/credential-free -import { describe } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect } from "vitest"; import { test } from "./e2e/fixtures/workflow-e2e-test.ts"; import { runManagedImageBuildlessE2e } from "./helpers/managed-image-buildless-e2e"; @@ -12,11 +15,24 @@ describe("managed image buildless onboarding orchestration contract", () => { timeout: 240_000, meta: { e2ePhases: [ + "validate managed-image fail-closed documentation", "validate mocked all-agent buildless orchestration boundaries", "release managed onboarding fixtures", ], }, }, ({ progress }) => { + progress.phase("validate managed-image fail-closed documentation"); + const commands = readFileSync( + path.join(import.meta.dirname, "..", "docs", "reference", "commands.mdx"), + "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.", + ); + expect(commands).toContain( + "Available catalog evidence that is incomplete, mixed, mutable, wrong-platform, or identity-inconsistent fails closed before sandbox creation.", + ); + progress.phase("validate mocked all-agent buildless orchestration boundaries"); runManagedImageBuildlessE2e(); progress.phase("release managed onboarding fixtures"); diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index f5fa6e03269..41420a29256 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -86,12 +86,10 @@ const childProcess = require("node:child_process"); const { EventEmitter } = require("node:events"); const fs = require("node:fs"); const commands = []; -runner.run = (command, opts = {}) => { - commands.push({ command: _n(command), env: opts.env || null }); if (_n(command).includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; - // provider-get returns not-found so messaging providers are created fresh - if (_n(command).includes("provider get")) return { status: 1 }; - return _n(command).includes("sandbox get") && _n(command).includes("my-assistant") ? { status: 0, stdout: Buffer.from("Name: my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; -}; +runner.run = require(${onboardScriptMocksPath}).createStatefulMessagingProviderRunner({ + commands, + readySandboxName: "my-assistant", +}); runner.runCapture = (command) => { if (_n(command).includes("sandbox get") && _n(command).includes("my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; @@ -364,12 +362,10 @@ const nonSlackMessagingEnvKeys = [ const commands = []; let registeredSandbox = null; -runner.run = (command, opts = {}) => { - const normalized = _n(command); - commands.push({ command: normalized, env: opts.env || null }); if (normalized.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; - if (normalized.includes("provider get")) return { status: 1 }; - return normalized.includes("sandbox get") && normalized.includes("my-assistant") ? { status: 0, stdout: Buffer.from("Name: my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; -}; +runner.run = require(${onboardScriptMocksPath}).createStatefulMessagingProviderRunner({ + commands, + readySandboxName: "my-assistant", +}); runner.runCapture = (command) => { if (_n(command).includes("sandbox get") && _n(command).includes("my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; @@ -534,16 +530,16 @@ const providers = Object.keys(credentialKeys), revisions = new Map(providers.map const rawGatewayCredential = ${JSON.stringify(rawGatewayCredential)}, gatewaySecrets = new Map(providers.map((name) => [name, rawGatewayCredential])); registry.registerSandbox({ name: "my-assistant", messaging: { schemaVersion: 1, plan: ${messagingPlanLiteral(["slack", "telegram", "whatsapp"])} } }); registry.addExtraProvider("my-assistant-extra-telegram-bot-token-agent-a"); registry.addExtraProvider("my-assistant-extra-telegram-bot-token-agent-b"); -runner.run = (command) => { +runner.run = (command, opts = {}) => { const normalized = _n(command); - commands.push({ command: normalized }); if (normalized.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + commands.push({ command: normalized, env: opts.env || null }); const providerGet = normalized.match(/provider get -g nemoclaw ([^ ]+)$/)?.[1]; if (providerGet === process.env.NEMOCLAW_TEST_FAIL_PROVIDER) return { status: 2, stderr: "transport unavailable" }; - if (providerGet && revisions.has(providerGet)) return { status: 0, stdout: "Name: " + providerGet + "\nType: " + (providerGet === "compatible-endpoint" ? "openai" : "generic") + "\nCredential keys: " + credentialKeys[providerGet] + "\nConfig keys: " + (providerGet === "compatible-endpoint" ? "OPENAI_BASE_URL" : "") + "\n" }; + if (providerGet && revisions.has(providerGet)) return { status: 0, stdout: "Name: " + providerGet + "\nType: " + (providerGet === "compatible-endpoint" ? "openai" : "nemoclaw-mcp-v1") + "\nCredential keys: " + credentialKeys[providerGet] + "\nConfig keys: " + (providerGet === "compatible-endpoint" ? "OPENAI_BASE_URL" : "") + "\n" }; const refresh = normalized.match(/provider update -g nemoclaw ([^ ]+)$/)?.[1]; if (refresh && gatewaySecrets.has(refresh)) { if (refresh === process.env.NEMOCLAW_TEST_FAIL_PROVIDER) return { status: 1 }; revisions.set(refresh, revisions.get(refresh) + 1); return { status: 0 }; } if (normalized.includes("provider get")) return { status: 1 }; return normalized.includes("sandbox get") && normalized.includes("my-assistant") ? { status: 0, stdout: Buffer.from("Name: my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; -}; +}; require(${onboardScriptMocksPath}).mockDockerSandboxLifecycleReleaseFromRunner(); runner.runCapture = (command) => { if (_n(command).includes("sandbox get") && _n(command).includes("my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; @@ -604,7 +600,6 @@ const { createSandbox } = require(${onboardPath}); .sort(); const denied = runScenario("my-assistant-extra-telegram-bot-token-agent-b"); assert.equal(denied.status, 1); - assert.match(denied.stderr, /preserved indeterminate attachments .*unexpected-exit/); const deniedPayload = parseStdoutJson(denied.stdout); const deniedCommands = (deniedPayload.commands as CommandEntry[]).map( ({ command }) => command, @@ -641,17 +636,17 @@ const { createSandbox } = require(${onboardPath}); assert.equal(createCommand.command.includes("GITHUB_TOKEN"), false); assert.equal(createCommand.rawCredentialInEnv, false); assert.deepEqual(registeredChannels, ["slack", "telegram", "whatsapp"]); - assert.deepEqual(deniedRefreshes.sort(), expectedProviders); + assert.deepEqual(deniedRefreshes, []); assert.equal( Object.values(deniedPayload.providerRevisions).filter((revision) => revision === 2).length, - expectedProviders.length - 1, + 0, ); assert.ok(deniedCommands.every((command) => !command.includes("sandbox create"))); assert.equal(deniedPayload.registered, null); assert.deepEqual(deniedPayload.temporaryCreateSources, []); assert.match( deniedPayload.error, - /did not publish attached provider 'my-assistant-extra-telegram-bot-token-agent-b' before Docker sandbox creation/, + /did not confirm messaging provider 'my-assistant-extra-telegram-bot-token-agent-b' before sandbox creation/, ); const combinedOutput = result.stdout + result.stderr + denied.stdout + denied.stderr; assert.equal( @@ -706,11 +701,11 @@ registry.registerSandbox({ }); runner.run = (command, opts = {}) => { const normalized = _n(command); - commands.push({ command: normalized, env: opts.env || null }); if (normalized.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; - if (normalized.includes("provider get -g nemoclaw my-assistant-telegram-bridge")) return { status: 0, stdout: "Name: my-assistant-telegram-bridge\nType: generic\nCredential keys: TELEGRAM_BOT_TOKEN\nConfig keys: \n" }; + commands.push({ command: normalized, env: opts.env || null }); + if (normalized.includes("provider get -g nemoclaw my-assistant-telegram-bridge")) return { status: 0, stdout: "Name: my-assistant-telegram-bridge\nType: nemoclaw-mcp-v1\nCredential keys: TELEGRAM_BOT_TOKEN\nConfig keys: \n" }; if (normalized.includes("provider get")) return { status: 1 }; return normalized.includes("sandbox get") && normalized.includes("my-assistant") ? { status: 0, stdout: Buffer.from("Name: my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; -}; +}; require(${onboardScriptMocksPath}).mockDockerSandboxLifecycleReleaseFromRunner(); runner.runCapture = (command) => { if (_n(command).includes("sandbox get") && _n(command).includes("my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; @@ -1238,10 +1233,14 @@ const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, "") const registry = require(${registryPath}); const commands = []; -runner.run = (command, opts = {}) => { - commands.push({ command: _n(command), env: opts.env || null }); - return { status: 0 }; -}; +runner.run = require(${onboardScriptMocksPath}).createStatefulMessagingProviderRunner({ + commands, + initialProviders: [ + ["my-assistant-discord-bridge", "nemoclaw-mcp-v1", "DISCORD_BOT_TOKEN"], + ["my-assistant-slack-bridge", "nemoclaw-mcp-v1", "SLACK_BOT_TOKEN"], + ["my-assistant-slack-app", "nemoclaw-mcp-v1", "SLACK_APP_TOKEN"], + ], +}); runner.runCapture = (command) => { // Existing sandbox that is ready if (_n(command).includes("sandbox get") && _n(command).includes("my-assistant")) return "my-assistant"; @@ -1292,9 +1291,7 @@ const { createSandbox } = require(${onboardPath}); "should NOT delete sandbox when providers already exist in gateway", ); - // Providers should still be upserted on reuse (credential refresh). - // Since the mock reports providers as existing (run returns status 0), - // upsertProvider issues 'update' rather than 'create'. + // Reuse refreshes credentials only after the mock returns the endpointless identity. const providerUpserts = payload.commands.filter((entry: CommandEntry) => entry.command.includes("provider update"), ); @@ -1347,12 +1344,10 @@ const childProcess = require("node:child_process"); const { EventEmitter } = require("node:events"); const commands = []; -runner.run = (command, opts = {}) => { - commands.push({ command: _n(command), env: opts.env || null }); if (_n(command).includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; - // provider-get returns not-found so messaging providers are created fresh - if (_n(command).includes("provider get")) return { status: 1 }; - return _n(command).includes("sandbox get") && _n(command).includes("my-assistant") ? { status: 0, stdout: Buffer.from("Name: my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; -}; +runner.run = require(${onboardScriptMocksPath}).createStatefulMessagingProviderRunner({ + commands, + readySandboxName: "my-assistant", +}); runner.runCapture = (command) => { if (_n(command).includes("sandbox get") && _n(command).includes("my-assistant")) return ""; if (_n(command).includes("sandbox list")) return "my-assistant Ready"; diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts index 2f801346e02..8df97f7d506 100644 --- a/test/onboard-policy-suggestions.test.ts +++ b/test/onboard-policy-suggestions.test.ts @@ -583,6 +583,22 @@ describe("onboard policy preset suggestions", () => { expect(suggestions.filter((name: string) => name === "slack")).toHaveLength(1); }); + it("omits credential-bound Hermes Discord egress until the channel is active", () => { + const inactive = computeSetupPresetSuggestions("open", { + agent: "hermes", + enabledChannels: [], + knownPresetNames: known, + }); + const active = computeSetupPresetSuggestions("open", { + agent: "hermes", + enabledChannels: ["discord"], + knownPresetNames: known, + }); + + expect(inactive).not.toContain("discord"); + expect(active).toContain("discord"); + }); + it("drops channel names that are not known presets", () => { const suggestions = computeSetupPresetSuggestions("balanced", { enabledChannels: ["telegram", "not-a-real-preset"], diff --git a/test/onboard-reservation-recreate.test.ts b/test/onboard-reservation-recreate.test.ts index 1bf56de8e52..9751bb3e1f7 100644 --- a/test/onboard-reservation-recreate.test.ts +++ b/test/onboard-reservation-recreate.test.ts @@ -75,6 +75,7 @@ runner.run = (command) => { ? { status: 0, stdout: Buffer.from("Name: my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; }; +require(${onboardScriptMocksPath}).mockDockerSandboxLifecycleReleaseFromRunner(); runner.runCapture = (command) => { const cmd = _n(command); if (cmd.includes("sandbox get") && cmd.includes("my-assistant")) return sandboxRecreated ? ["my-assistant", "Id: sbx-4f2a91c0d7"].join(String.fromCharCode(10)) : sandboxDeleted ? "" : ["my-assistant", "Id: sbx-4f2a91c0d7"].join(String.fromCharCode(10)); @@ -147,9 +148,10 @@ const { createSandbox } = require(${onboardPath}); NEMOCLAW_TEST_MANAGED_IMAGE_FALLBACK: "1", NEMOCLAW_SANDBOX_PREBUILD: "1", }), + timeoutMs: 30_000, }); - assert.equal(result.status, 0, result.stderr); + assert.equal(result.status, 0, result.stderr || result.error?.message); const payload = trailingJsonPayload<{ sandboxName: string; events: Array<{ kind: string; cmd?: string; name?: string }>; diff --git a/test/onboard-sandbox-build.test.ts b/test/onboard-sandbox-build.test.ts index e65842dd293..65a294a17b7 100644 --- a/test/onboard-sandbox-build.test.ts +++ b/test/onboard-sandbox-build.test.ts @@ -58,7 +58,9 @@ const defaultCalls = []; runner.run = (command, opts = {}) => { const normalized = _n(command); commands.push({ command: normalized, env: opts.env || null }); - if (normalized.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (normalized.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return normalized.includes("sandbox get") && normalized.includes("my-assistant") ? { status: 0, stdout: Buffer.from("Name: my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; @@ -281,7 +283,9 @@ agentOnboard.createAgentSandbox = () => { runner.run = (command, opts = {}) => { const normalized = _n(command); commands.push({ command: normalized, env: opts.env || null }); - if (normalized.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (normalized.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return normalized.includes("sandbox get hermes-sandbox") ? { status: 0, stdout: Buffer.from("Name: hermes-sandbox\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; }; runner.runFile = (file, args = [], opts = {}) => { @@ -481,7 +485,9 @@ buildContext.stageOptimizedSandboxBuildContext = () => { runner.run = (command, opts = {}) => { const normalized = _n(command); commands.push({ command: normalized, env: opts.env || null }); - if (normalized.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (normalized.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return normalized.includes("sandbox get") && normalized.includes("my-assistant") ? { status: 0, stdout: Buffer.from("Name: my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; @@ -591,7 +597,9 @@ const commands = []; runner.run = (command, opts = {}) => { const normalized = _n(command); commands.push({ command: normalized, env: opts.env || null }); - if (normalized.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (normalized.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return normalized.includes("sandbox get") && normalized.includes("my-assistant") ? { status: 0, stdout: Buffer.from("Name: my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; @@ -693,7 +701,9 @@ const commands = []; runner.run = (command, opts = {}) => { const normalized = _n(command); commands.push({ command: normalized, env: opts.env || null }); - if (normalized.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (normalized.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return normalized.includes("sandbox get") && normalized.includes("my-assistant") ? { status: 0, stdout: Buffer.from("Name: my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; diff --git a/test/onboard-sandbox-recreation.test.ts b/test/onboard-sandbox-recreation.test.ts index a4b9f7cab52..7db31a2922d 100644 --- a/test/onboard-sandbox-recreation.test.ts +++ b/test/onboard-sandbox-recreation.test.ts @@ -142,7 +142,9 @@ runner.run = (command, opts = {}) => { const cmd = _n(command); _deleted = _deleted || cmd.includes("sandbox delete"); commands.push({ command: cmd, env: opts.env || null }); - if (cmd.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (cmd.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return cmd.includes("sandbox get") && cmd.includes("my-assistant") ? { status: 0, stdout: Buffer.from("my-assistant\nId: " + _sandboxId + "\n"), stderr: Buffer.alloc(0) } : { status: 0 }; @@ -277,7 +279,9 @@ runner.run = (command) => { const cmd = _n(command); _deleted = _deleted || cmd.includes("sandbox delete"); events.push({ kind: "run", cmd }); - if (cmd.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (cmd.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return cmd.includes("sandbox get") && cmd.includes("my-assistant") ? { status: 0, stdout: Buffer.from("my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; @@ -432,7 +436,9 @@ runner.run = (command) => { const cmd = _n(command); _deleted = _deleted || cmd.includes("sandbox delete"); events.push({ kind: "run", cmd }); - if (cmd.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (cmd.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return cmd.includes("sandbox get") && cmd.includes("my-assistant") ? { status: 0, stdout: Buffer.from("my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; @@ -564,6 +570,9 @@ runner.run = (command) => { events.push({ kind: "run", cmd }); if (cmd.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; if (cmd.includes("sandbox delete")) sandboxDeleted = true; + if (cmd.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return cmd.includes("sandbox get") && cmd.includes("my-assistant") ? { status: 0, stdout: Buffer.from("my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; @@ -713,7 +722,9 @@ runner.run = (command, opts = {}) => { const cmd = _n(command); _deleted = _deleted || cmd.includes("sandbox delete"); commands.push({ command: cmd, env: opts.env || null }); - if (cmd.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (cmd.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return cmd.includes("sandbox get") && cmd.includes("my-assistant") ? { status: 0, stdout: Buffer.from("my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; @@ -857,7 +868,9 @@ runner.run = (command, opts = {}) => { } } commands.push({ command: cmd, env: opts.env || null }); - if (cmd.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (cmd.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return cmd.includes("sandbox get") && cmd.includes("my-assistant") ? { status: 0, stdout: Buffer.from("my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; @@ -994,7 +1007,9 @@ runner.run = (command, opts = {}) => { } } commands.push({ command: cmd, env: opts.env || null }); - if (cmd.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (cmd.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return cmd.includes("sandbox get") && cmd.includes("my-assistant") ? { status: 0, stdout: Buffer.from("my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; @@ -1129,6 +1144,9 @@ runner.run = (command, opts = {}) => { commands.push({ command: cmd, env: opts.env || null }); if (cmd.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; if (cmd.includes("sandbox delete")) sandboxDeleted = true; + if (cmd.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return cmd.includes("sandbox get") && cmd.includes("my-assistant") ? { status: 0, stdout: Buffer.from("my-assistant\nId: sbx-4f2a91c0d7\n"), stderr: Buffer.alloc(0) } : { status: 0 }; @@ -1287,7 +1305,9 @@ runner.run = (command, opts = {}) => { const cmd = _n(command); _deleted = _deleted || cmd.includes("sandbox delete"); commands.push({ command: cmd, env: opts.env || null }); - if (cmd.includes("sandbox list")) return { status: 0, stdout: "No sandboxes found." }; + if (cmd.includes("sandbox list")) { + return { status: 0, stdout: Buffer.from("No sandboxes found.\n"), stderr: Buffer.alloc(0) }; + } return cmd.includes("sandbox get") && cmd.includes("my-assistant") && sandboxCreated ? { status: 0, stdout: Buffer.from("my-assistant\nId: sbx-fresh-create\n"), stderr: Buffer.alloc(0) } : { status: 0 }; diff --git a/test/package-contract/messaging-provider-profile-path.test.ts b/test/package-contract/messaging-provider-profile-path.test.ts new file mode 100644 index 00000000000..b917c93c558 --- /dev/null +++ b/test/package-contract/messaging-provider-profile-path.test.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRequire } from "node:module"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const REPOSITORY_ROOT = path.resolve(import.meta.dirname, "../.."); + +describe("compiled messaging credential profile path", () => { + it("uses the packaged CLI repository root (#9875)", () => { + const repositoryRoot = require( + path.join(REPOSITORY_ROOT, "dist", "lib", "core", "repository-root.js"), + ) as { REPOSITORY_ROOT: string }; + const profile = require( + path.join(REPOSITORY_ROOT, "dist", "lib", "messaging", "provider-profile.js"), + ) as { + messagingCredentialProviderProfilePath(root: string): string; + }; + + expect(repositoryRoot.REPOSITORY_ROOT).toBe(REPOSITORY_ROOT); + expect(profile.messagingCredentialProviderProfilePath(repositoryRoot.REPOSITORY_ROOT)).toBe( + path.join(REPOSITORY_ROOT, "nemoclaw-blueprint", "provider-profiles", "nemoclaw-mcp-v1.yaml"), + ); + }); +}); diff --git a/test/permissive-runtime.test.ts b/test/permissive-runtime.test.ts index 38f6e742d35..67f5d3f37c7 100644 --- a/test/permissive-runtime.test.ts +++ b/test/permissive-runtime.test.ts @@ -87,7 +87,18 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { it("keeps the Hermes Discord provider binding in Shields down", () => { let stagedPolicy = ""; const out = buildRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { - livePolicyYaml: "", + livePolicyYaml: YAML.stringify({ + network_policies: { + discord: { + endpoints: [ + { + host: "discord.com", + credential_binding: { provider: "hermes-box-discord-bridge" }, + }, + ], + }, + }, + }), readBasePolicy: () => HERMES_DISCORD_PERMISSIVE, sandboxName: "hermes-box", writeTempPolicy: (yaml) => { @@ -121,12 +132,39 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { expect(stagedPolicy).not.toContain("{sandboxName}"); }); + it("omits Hermes Discord egress when no live provider binding exists", () => { + let stagedPolicy = ""; + const out = buildRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { + livePolicyYaml: "", + readBasePolicy: () => HERMES_DISCORD_PERMISSIVE, + sandboxName: "hermes-box", + writeTempPolicy: (yaml) => { + stagedPolicy = yaml; + return "/staged-hermes-permissive.yaml"; + }, + }); + + expect(out).toBe("/staged-hermes-permissive.yaml"); + expect(YAML.parse(stagedPolicy).network_policies.discord).toBeUndefined(); + expect(stagedPolicy).not.toContain("{sandboxName}"); + }); + it("rejects an unsafe Hermes sandbox name before staging Shields down", () => { const writeTempPolicy = vi.fn(() => "/must-not-stage.yaml"); expect(() => buildRuntimePermissivePolicy("/unused-hermes-permissive.yaml", { - livePolicyYaml: "", + livePolicyYaml: YAML.stringify({ + network_policies: { + discord: { + endpoints: [ + { + credential_binding: { provider: "bad:provider-discord-bridge" }, + }, + ], + }, + }, + }), readBasePolicy: () => HERMES_DISCORD_PERMISSIVE, sandboxName: "bad:provider", writeTempPolicy, diff --git a/test/protected-managed-image-build-script.test.ts b/test/protected-managed-image-build-script.test.ts index a372aa48aa3..d658771b1d2 100644 --- a/test/protected-managed-image-build-script.test.ts +++ b/test/protected-managed-image-build-script.test.ts @@ -340,6 +340,21 @@ describe("protected managed-image build-cache boundary", () => { }); }); + it("binds every protected build to the selected target architecture", () => { + stubBuildInvocation(); + + const result = runBuild(REPO_ROOT, ["--platform", "linux/arm64"]); + + expect(result.status, result.stderr).toBe(0); + expect(recordedBuildInvocations()).toHaveLength(3); + expect(recordedBuildInvocation("openclaw")).toContain("--platform linux/arm64"); + expect(recordedBuildInvocation("openclaw")).toContain("--build-arg TARGETARCH=arm64"); + expect(recordedBuildInvocation("hermes")).toContain("--platform linux/arm64"); + expect(recordedBuildInvocation("hermes")).toContain("--build-arg TARGETARCH=arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--platform linux/arm64"); + expect(recordedBuildInvocation("langchain-deepagents-code")).toContain("--build-arg TARGETARCH=arm64"); + }); + it("passes each agent one empty absolute cache export root", () => { const cacheRoot = path.join(testRoot, "export-cache"); stubBuildInvocation(); diff --git a/test/protected-managed-image-contract.test.ts b/test/protected-managed-image-contract.test.ts index 6719c68a6c5..2333ff53cc7 100644 --- a/test/protected-managed-image-contract.test.ts +++ b/test/protected-managed-image-contract.test.ts @@ -56,6 +56,8 @@ const PLATFORM_DIGESTS = { hermes: `sha256:${"2".repeat(64)}`, dcode: `sha256:${"3".repeat(64)}`, } as const; +const DCODE_BASE_REF = + `ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base@${PLATFORM_DIGESTS.dcode}`; const E2E_WORKFLOW = YAML.parse( readFileSync(path.join(ROOT, ".github", "workflows", "e2e.yaml"), "utf8"), ) as { @@ -123,6 +125,10 @@ printf '%s %s\n' "$digest" "$1" encoding: "utf8", env: { ...process.env, + DCODE_BASE_CONTRACT: JSON.stringify({ + platformReferences: { "linux/amd64": DCODE_BASE_REF }, + }), + DCODE_BASE_REF, GITHUB_OUTPUT: outputPath, PATH: `${fakeBin}:${process.env.PATH ?? ""}`, PLATFORM: "linux/amd64", @@ -175,7 +181,7 @@ describe("protected managed-image build contract", () => { it.each([ ["managed-image-multiarch-startup", "Resolve exact platform base images"], ["managed-image-protected-runtime", "Resolve exact amd64 runtime base images"], - ])("%s keeps non-Hermes base resolution separate", (jobId, stepName) => { + ])("%s keeps immutable DCode resolution separate from Hermes", (jobId, stepName) => { const { result, output } = runBaseResolution(jobId, stepName); expect(result.status, result.stderr).toBe(0); expect(Object.fromEntries(output.trim().split("\n").map((line) => line.split("=")))).toEqual( diff --git a/test/runtime-state-mutation-control.test.ts b/test/runtime-state-mutation-control.test.ts index c3992919a9c..f4df64ca696 100644 --- a/test/runtime-state-mutation-control.test.ts +++ b/test/runtime-state-mutation-control.test.ts @@ -187,9 +187,9 @@ def process(pid, state, parent, start, uid, command, inode): root_uid = control.ROOT_UID pid1 = process(1, "S", 0, "100", root_uid, (control.OPENSHELL_ARGV0,), 101) +stopped_pid1 = process(1, "T", 0, "100", root_uid, (control.OPENSHELL_ARGV0,), 101) def start_process(pid, command): return process(pid, "S", 1, str(190 + pid), 1001, command, 100 + pid) - start = start_process(10, (b"/bin/bash", control.NEMOCLAW_START_PATH, b"/bin/bash")) prefixed_start = start_process(11, (b"/bin/bash", b"--noprofile", control.NEMOCLAW_START_PATH)) reordered_start = start_process(12, (b"/bin/bash", b"/bin/bash", control.NEMOCLAW_START_PATH)) @@ -514,6 +514,11 @@ control._capture_process = lambda pid: { 77: gateway, 78: auxiliary, }.get(pid) +control.PROCESS_STATE_SECONDS = 0 +results["running_supervisor_hold"] = code(lambda: real_hold_exact_processes(fence, "mnt:[401]", activation)) +control.PROCESS_STATE_SECONDS = 5 +control._prove_fence_shape = lambda _fence, _mount: (stopped_pid1, start) +control._recapture_reference = lambda reference, _code="fenced-process-drift": stopped_pid1 if reference.pid == 1 else {10: start, 77: gateway, 78: auxiliary}[reference.pid] real_hold_exact_processes(fence, "mnt:[401]", activation) results["hold_events"] = hold_events @@ -831,8 +836,9 @@ control._recapture_reference = lambda reference, _code="fenced-process-drift": s by_release_pid[reference.pid] ) def resume_reference(reference): - release_events.append(["resume", reference.pid]) - release_states[reference.pid] = "S" + if release_states[reference.pid] in ("T", "t"): + release_events.append(["resume", reference.pid]) + release_states[reference.pid] = "S" return state_process(by_release_pid[reference.pid]) control._resume_reference = resume_reference control._prove_released_activation = lambda *_args: release_events.append(["health"]) @@ -1260,7 +1266,7 @@ beforeAll(() => { }); describe("runtime state mutation controller", () => { - it("accepts only the canonical adapter request and recomputes its transaction binding (#7744)", () => { + it("accepts only the canonical adapter request and transaction binding (#7744)", () => { expect(harnessResult.canonical).toMatch(/^[0-9a-f]{64}$/u); expect(harnessResult).toMatchObject({ noncanonical: "envelope-schema", @@ -1294,9 +1300,9 @@ describe("runtime state mutation controller", () => { discovered_pid1: 1, discovered_start: 10, wrong_pid1: "supervisor-unavailable", + running_supervisor_hold: "supervisor-not-host-stopped", }); expect(harnessResult.hold_events).toEqual([ - ["stop", 1], ["stop", 10], ["stop", 77], ["stop", 78], @@ -1422,8 +1428,7 @@ describe("runtime state mutation controller", () => { }); }); - it("records release intent before resuming PID1 last and resolves retry ambiguity (#7744)", () => { - const sigcont = harnessResult.sigcont as number; + it("records release intent before resuming exact writers and leaves PID1 to host authority (#9485)", () => { expect(harnessResult).toMatchObject({ release: "activation-proven", released_marker: true, @@ -1440,7 +1445,6 @@ describe("runtime state mutation controller", () => { ["resume", 78], ["resume", 10], ["health"], - ["signal", 1, sigcont], ]); expect(harnessResult.release_retry_events).toEqual([ ["verify-checkpoint"], @@ -1453,7 +1457,6 @@ describe("runtime state mutation controller", () => { ["resume", 77], ["resume", 10], ["health"], - ["signal", 1, sigcont], ]); expect(harnessResult.persistent_exit_release).toBe("activation-process-drift"); const events = harnessResult.state_events as unknown[][]; diff --git a/test/runtime-state-mutation-hermes-publisher.test.ts b/test/runtime-state-mutation-hermes-publisher.test.ts index 3bdf3ba0232..50b6c418532 100644 --- a/test/runtime-state-mutation-hermes-publisher.test.ts +++ b/test/runtime-state-mutation-hermes-publisher.test.ts @@ -68,6 +68,8 @@ def marker(nonce="d" * 64, selectors=None, provider_id="docker"): "transactionId": "a" * 64, "providerId": provider_id, "stateRoot": "/sandbox/.hermes", + "stateRootDevice": "101", + "stateRootInode": "202", "plan": plan_text, "planSha256": hashlib.sha256(plan_text.encode()).hexdigest(), "projectionSha256": "b" * 64, @@ -350,6 +352,10 @@ describe("Hermes runtime state mutation publisher", () => { expect(result.extra_selector).toBe("publisher-plan-selector-mismatch"); const events = result.events as Array<[string, string[]?]>; expect(events.filter(([action]) => action === "begin-shields-transition")).toHaveLength(2); + expect(events).toContainEqual([ + "begin-shields-transition", + expect.arrayContaining(["--expected-hermes-device", "101", "--expected-hermes-inode", "202"]), + ]); expect(events).toContainEqual([ "run-state-dir-transition", expect.arrayContaining(["--state-action", "lock"]), diff --git a/test/support/dcode-start-script-fixture.ts b/test/support/dcode-start-script-fixture.ts index a74a3910c37..18cc65ebb79 100644 --- a/test/support/dcode-start-script-fixture.ts +++ b/test/support/dcode-start-script-fixture.ts @@ -20,6 +20,8 @@ export type ManagedProxyScriptOptions = { export type StartScriptFixtureOptions = ManagedProxyScriptOptions & { envDir?: string; + fallbackCaFile?: string; + liveCaFile?: string; markerDir?: string; }; @@ -90,7 +92,16 @@ export function makeStartScriptFixture( const envFile = path.join(envDir, "proxy-env.sh"); const scriptPath = path.join(tempDir, "start.sh"); const markerDir = options.markerDir; - const original = fs.readFileSync(START_SCRIPT, "utf8"); + const original = fs + .readFileSync(START_SCRIPT, "utf8") + .replaceAll( + "/etc/openshell-tls/ca-bundle.pem", + options.liveCaFile ?? "/etc/openshell-tls/ca-bundle.pem", + ) + .replaceAll( + "/run/nemoclaw/managed-startup-ca-bundle.pem", + options.fallbackCaFile ?? "/run/nemoclaw/managed-startup-ca-bundle.pem", + ); assert.ok(original.includes("local target=/tmp/nemoclaw-proxy-env.sh")); assert.ok(original.includes('tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"')); assert.ok(original.includes("local marker_dir=/sandbox/.deepagents")); diff --git a/tools/advisors/repo-read-only-tools.mts b/tools/advisors/repo-read-only-tools.mts index 42744a69893..ab220cb1de7 100644 --- a/tools/advisors/repo-read-only-tools.mts +++ b/tools/advisors/repo-read-only-tools.mts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { + type AgentToolResult, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, @@ -13,9 +14,11 @@ import { defineTool, type LsOperations, type ToolDefinition, + type TruncationResult, } from "@earendil-works/pi-coding-agent"; const PI_UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; +export const MAX_ADVISOR_TOOL_RESULT_JSON_BYTES = 16 * 1024; type RepoPathGuard = { resolveExisting(candidate: string): Promise; @@ -29,6 +32,112 @@ export type AdvisorReadObservation = Readonly<{ reachesEnd: boolean; }>; +type TruncationDetails = Readonly<{ + truncation?: TruncationResult; +}>; + +function compactTruncationDetails(details: T): T { + if (details === undefined || typeof details !== "object" || details === null) return details; + const record = details as Record; + const truncation = record.truncation; + if (typeof truncation !== "object" || truncation === null) return details; + + return { + ...record, + truncation: { ...(truncation as Record), content: "" }, + } as T; +} + +function serializedToolResultBytes(result: AgentToolResult): number { + return Buffer.byteLength(JSON.stringify(result), "utf8"); +} + +/** + * Keep native Pi tool-result session records readable by the synthesis advisor. + * Pi's default 50 KiB truncation details repeat the visible content, and JSON escaping + * can expand it again. Bound the serialized result instead of assuming raw text bytes + * predict the eventual JSONL line size. + */ +function boundAdvisorToolResult( + result: AgentToolResult, + continuationNotice: (outputLines: number) => string, +): AgentToolResult { + const originalDetails = result.details as (T & TruncationDetails) | undefined; + const compactDetails = compactTruncationDetails(result.details); + const compactResult = { ...result, details: compactDetails }; + if (serializedToolResultBytes(compactResult) <= MAX_ADVISOR_TOOL_RESULT_JSON_BYTES) { + return compactResult; + } + + const textIndex = result.content.findIndex((item) => item.type === "text"); + const textItem = result.content[textIndex]; + if (textIndex < 0 || textItem?.type !== "text") { + return { + content: [ + { + type: "text", + text: "[Advisor tool result omitted because it exceeds the session safety limit.]", + }, + ], + details: undefined, + } as AgentToolResult; + } + + const originalTruncation = originalDetails?.truncation; + const sourceText = originalTruncation?.content || textItem.text; + const sourceLines = sourceText.split("\n"); + const totalLines = originalTruncation?.totalLines ?? sourceLines.length; + const totalBytes = originalTruncation?.totalBytes ?? Buffer.byteLength(sourceText, "utf8"); + + const candidate = (outputLines: number): AgentToolResult => { + const prefix = sourceLines.slice(0, outputLines).join("\n"); + const notice = continuationNotice(outputLines); + const text = prefix.length > 0 ? `${prefix}\n\n${notice}` : notice; + const truncation: TruncationResult = { + content: "", + truncated: true, + truncatedBy: "bytes", + totalLines, + totalBytes, + outputLines, + outputBytes: Buffer.byteLength(prefix, "utf8"), + lastLinePartial: false, + firstLineExceedsLimit: outputLines === 0, + maxLines: originalTruncation?.maxLines ?? Number.MAX_SAFE_INTEGER, + maxBytes: MAX_ADVISOR_TOOL_RESULT_JSON_BYTES, + }; + const details = { + ...((compactDetails as Record | undefined) ?? {}), + truncation, + } as T; + return { + ...result, + content: result.content.map((item, index) => + index === textIndex && item.type === "text" ? { ...item, text } : item, + ), + details, + }; + }; + + let low = 0; + let high = sourceLines.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (serializedToolResultBytes(candidate(middle)) <= MAX_ADVISOR_TOOL_RESULT_JSON_BYTES) { + low = middle; + } else { + high = middle - 1; + } + } + const bounded = candidate(low); + if (serializedToolResultBytes(bounded) <= MAX_ADVISOR_TOOL_RESULT_JSON_BYTES) return bounded; + + return { + ...bounded, + content: [bounded.content[textIndex]!], + }; +} + function isContainedPath(root: string, candidate: string): boolean { const relative = path.relative(root, candidate); return ( @@ -115,8 +224,13 @@ export function createRepoConfinedReadOnlyTools( onUpdate, context, ); - const truncation = result.details?.truncation; const offset = Math.max(1, input.offset ?? 1); + const boundedResult = boundAdvisorToolResult( + result, + (outputLines) => + `[Advisor session limit reached. Use offset=${offset + outputLines} to continue.]`, + ); + const truncation = boundedResult.details?.truncation; const returnedLines = truncation?.outputLines ?? input.limit; onRead?.({ path: resolvedPath, @@ -125,40 +239,49 @@ export function createRepoConfinedReadOnlyTools( fileSize: (await fs.promises.stat(resolvedPath)).size, reachesEnd: input.limit === undefined && !truncation?.truncated, }); - return result; + return boundedResult; }; const grep = createGrepToolDefinition(cwd); const executeGrep = grep.execute; grep.execute = async (toolCallId, input, signal, onUpdate, context) => - executeGrep( - toolCallId, - { ...input, path: await guard.resolveExisting(input.path || ".") }, - signal, - onUpdate, - context, + boundAdvisorToolResult( + await executeGrep( + toolCallId, + { ...input, path: await guard.resolveExisting(input.path || ".") }, + signal, + onUpdate, + context, + ), + () => "[Advisor session limit reached. Refine the grep query to continue.]", ); const find = createFindToolDefinition(cwd); const executeFind = find.execute; find.execute = async (toolCallId, input, signal, onUpdate, context) => - executeFind( - toolCallId, - { ...input, path: await guard.resolveExisting(input.path || ".") }, - signal, - onUpdate, - context, + boundAdvisorToolResult( + await executeFind( + toolCallId, + { ...input, path: await guard.resolveExisting(input.path || ".") }, + signal, + onUpdate, + context, + ), + () => "[Advisor session limit reached. Refine the find query to continue.]", ); const ls = createLsToolDefinition(cwd, { operations: createGuardedLsOperations(guard) }); const executeLs = ls.execute; ls.execute = async (toolCallId, input, signal, onUpdate, context) => - executeLs( - toolCallId, - { ...input, path: await guard.resolveExisting(input.path || ".") }, - signal, - onUpdate, - context, + boundAdvisorToolResult( + await executeLs( + toolCallId, + { ...input, path: await guard.resolveExisting(input.path || ".") }, + signal, + onUpdate, + context, + ), + () => "[Advisor session limit reached. Read a narrower directory to continue.]", ); return [defineTool(read), defineTool(grep), defineTool(find), defineTool(ls)]; diff --git a/tools/e2e/check-semantic-phases.mts b/tools/e2e/check-semantic-phases.mts index a7795771ccf..4198fc1e209 100644 --- a/tools/e2e/check-semantic-phases.mts +++ b/tools/e2e/check-semantic-phases.mts @@ -411,6 +411,10 @@ const OBSERVED_CHILD_PROGRESS_POLICIES = new Map; type WorkflowStep = WorkflowRecord & { env?: WorkflowRecord; @@ -267,6 +267,9 @@ 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", diff --git a/tools/e2e/managed-image-multiarch-workflow-boundary.mts b/tools/e2e/managed-image-multiarch-workflow-boundary.mts index 1118dd7756d..92de6129e55 100644 --- a/tools/e2e/managed-image-multiarch-workflow-boundary.mts +++ b/tools/e2e/managed-image-multiarch-workflow-boundary.mts @@ -29,7 +29,7 @@ type WorkflowStep = WorkflowRecord & { const JOB_ID = PROTECTED_MANAGED_IMAGE_MULTIARCH_JOB_ID; const PROTECTED_RUNTIME_JOB_ID = "managed-image-protected-runtime"; -const SELECTOR = `\${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), '${JOB_ID}') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), '${PROTECTED_RUNTIME_JOB_ID}')) }}`; +const SELECTOR = `\${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), '${JOB_ID}') || contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), '${PROTECTED_RUNTIME_JOB_ID}')) }}`; const ACTIVATION_PATH = PROTECTED_MANAGED_IMAGE_ACTIVATION_PATH; const DIRECT_TEST_PATH = "test/e2e/live/managed-image-multiarch-startup.test.ts"; const REGISTRY_IMAGE = @@ -110,7 +110,9 @@ export function validateManagedImageMultiarchWorkflow(workflow: WorkflowRecord): return [`workflow missing ${JOB_ID} job`]; } - if (job.needs !== "generate-matrix") errors.push(`${JOB_ID} must depend on generate-matrix`); + if (!isDeepStrictEqual(job.needs, ["base-image-publication", "generate-matrix"])) { + errors.push(`${JOB_ID} must depend on base-image-publication and generate-matrix`); + } if (job.if !== SELECTOR) errors.push(`${JOB_ID} must use the trusted execution plan`); if (job["runs-on"] !== "${{ matrix.runner }}") { errors.push(`${JOB_ID} must run on the native matrix runner`); @@ -192,6 +194,7 @@ export function validateManagedImageMultiarchWorkflow(workflow: WorkflowRecord): requireFragments(errors, guard, [ '"NVIDIA/NemoClaw"', '"refs/heads/main"', + '"$REF" == refs/heads/*', '"push"', '"workflow_dispatch"', '[[ "$CHECKOUT_SHA" =~ ^[a-f0-9]{40}$ && "$BASE_SHA" =~ ^[a-f0-9]{40}$ ]]', @@ -288,6 +291,11 @@ export function validateManagedImageMultiarchWorkflow(workflow: WorkflowRecord): ]); const bases = requireStep(errors, steps, "Resolve exact platform base images"); + requireValues(errors, `${JOB_ID} exact base resolution`, record(bases?.env), { + DCODE_BASE_CONTRACT: + "${{ needs.base-image-publication.outputs.dcode_base_contract }}", + PLATFORM: "${{ matrix.platform }}", + }); requireFragments(errors, bases, [ 'arch="${PLATFORM#linux/}"', 'docker buildx imagetools inspect "$alias" --raw', @@ -295,8 +303,18 @@ export function validateManagedImageMultiarchWorkflow(workflow: WorkflowRecord): 'reference="${repository}@${digest}"', '"sha256:$(sha256sum "$exact_raw" | awk \'{print $1}\')" == "$digest"', "ghcr.io/nvidia/nemoclaw/sandbox-base:latest", - "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base:latest", + "'.platformReferences[$platform]' <<< \"$DCODE_BASE_CONTRACT\"", + 'docker buildx imagetools inspect "$dcode_reference" --raw', + '"sha256:$(sha256sum "$work_dir/dcode-exact.raw" | awk \'{print $1}\')" == "$dcode_digest"', + "printf 'dcode=%s\\n' \"$dcode_reference\" >> \"$GITHUB_OUTPUT\"", ]); + if ( + text(bases?.run).includes( + "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base:latest", + ) + ) { + errors.push(`${JOB_ID} must not resolve the DCode base from a mutable alias`); + } if (text(bases?.run).includes("ghcr.io/nvidia/nemoclaw/hermes-sandbox-base:latest")) { errors.push(`${JOB_ID} must resolve Hermes from the immutable reviewed Dockerfile index`); } diff --git a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts index 6a8eb46d1bc..e7031afd020 100644 --- a/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts +++ b/tools/e2e/managed-image-protected-runtime-workflow-boundary.mts @@ -14,14 +14,14 @@ type WorkflowStep = WorkflowRecord & { const JOB_ID = "managed-image-protected-runtime"; const SELECTOR = - "${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }}"; + "${{ always() && github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && needs['base-image-publication'].result == 'success' && needs['generate-matrix'].result == 'success' && needs['managed-image-multiarch-startup'].result == 'success' && contains(fromJSON(needs.generate-matrix.outputs.selected_jobs), 'managed-image-protected-runtime') }}"; const ACTIVATION_PATH = "ci/protected-managed-image-runtime-activation-v1.json"; const LIVE_TEST_PATH = "test/e2e/live/managed-image-protected-runtime.test.ts"; const REGISTRY_IMAGE = "docker.io/library/registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373"; const REVIEWED_HERMES_PLATFORM_ACTION = "./.github/actions/resolve-reviewed-hermes-platform"; const GUARDED_NVIDIA_API_KEY = - "${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_API_KEY || '' }}"; + "${{ github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') && secrets.NVIDIA_API_KEY || '' }}"; // Keep lane-specific trust assertions explicit: the multiarch lane executes // candidate code directly, while this GPU lane keeps secrets in trusted code @@ -94,8 +94,16 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR const job = record(record(workflow.jobs)[JOB_ID]); if (Object.keys(job).length === 0) return [`workflow missing ${JOB_ID} job`]; - if (!isDeepStrictEqual(job.needs, ["generate-matrix", "managed-image-multiarch-startup"])) { - errors.push(`${JOB_ID} must depend on generate-matrix and managed-image-multiarch-startup`); + if ( + !isDeepStrictEqual(job.needs, [ + "base-image-publication", + "generate-matrix", + "managed-image-multiarch-startup", + ]) + ) { + errors.push( + `${JOB_ID} must depend on base-image-publication, generate-matrix, and managed-image-multiarch-startup`, + ); } if (job.if !== SELECTOR) errors.push(`${JOB_ID} must use the trusted execution plan`); if (job["runs-on"] !== "linux-amd64-gpu-rtxpro6000-latest-1") { @@ -160,6 +168,7 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR requireFragments(errors, guard, [ '"NVIDIA/NemoClaw"', '"refs/heads/main"', + '"$REF" == refs/heads/*', '"push"', '"workflow_dispatch"', '[[ "$CHECKOUT_SHA" =~ ^[a-f0-9]{40}$ && "$BASE_SHA" =~ ^[a-f0-9]{40}$ ]]', @@ -264,14 +273,22 @@ export function validateManagedImageProtectedRuntimeWorkflow(workflow: WorkflowR }); const bases = requireStep(errors, workflowSteps, "Resolve exact amd64 runtime base images"); + requireValues(errors, `${JOB_ID} runtime base env`, record(bases?.env), { + DCODE_BASE_REF: "${{ needs.base-image-publication.outputs.dcode_base_ref }}", + }); requireFragments(errors, bases, [ 'docker buildx imagetools inspect "$alias" --raw', '.platform.os == "linux" and .platform.architecture == "amd64"', 'reference="${repository}@${digest}"', '"sha256:$(sha256sum "$exact_raw" | awk \'{print $1}\')" == "$digest"', "ghcr.io/nvidia/nemoclaw/sandbox-base:latest", - "ghcr.io/nvidia/nemoclaw/langchain-deepagents-code-sandbox-base:latest", + 'docker buildx imagetools inspect "$DCODE_BASE_REF" --raw', + 'dcode_digest="${DCODE_BASE_REF##*@}"', + 'printf \'dcode=%s\\n\' "$DCODE_BASE_REF" >> "$GITHUB_OUTPUT"', ]); + if (text(bases?.run).includes("langchain-deepagents-code-sandbox-base:latest")) { + errors.push(`${JOB_ID} must not resolve the DCode base from a mutable alias`); + } if (text(bases?.run).includes("ghcr.io/nvidia/nemoclaw/hermes-sandbox-base:latest")) { errors.push(`${JOB_ID} must resolve Hermes from the immutable reviewed Dockerfile index`); } diff --git a/tools/e2e/mcp-dev-workflow-boundary-digests.mts b/tools/e2e/mcp-dev-workflow-boundary-digests.mts index 6adab749126..bdaa805a6ee 100644 --- a/tools/e2e/mcp-dev-workflow-boundary-digests.mts +++ b/tools/e2e/mcp-dev-workflow-boundary-digests.mts @@ -10,7 +10,7 @@ export const MCP_DEV_JOB_EXECUTION_CONTEXT_SHA256 = export const MCP_DEV_TRUSTED_NODE_SETUP_CONTENT_SHA256 = "504821ad93c57971d0281ef1130ed6008fadd331bd56acb1a6b5e6a3358f3e49"; export const MCP_DEV_TRUSTED_PREFIX_CONTENT_SHA256 = - "067df18297c3b5e5175dc11de071a0f3c261aa894db6141b8b789d67f5e9c0d1"; + "ee28f7ecc4ab0aed53c83793e8c6f57045a49d0cca38ed80786a83eeb5c0b2fc"; export const MCP_DEV_POST_INSTALL_TRANSITION_CONTENT_SHA256 = "62cf2ee01ac7192f41fc7b2b071de729da8bacec1e4f693da1ec6f0b1f4723c0"; diff --git a/tools/e2e/operations-workflow-boundary.mts b/tools/e2e/operations-workflow-boundary.mts index 6796c876b96..021e1b0a024 100644 --- a/tools/e2e/operations-workflow-boundary.mts +++ b/tools/e2e/operations-workflow-boundary.mts @@ -32,15 +32,20 @@ const PR_GATE_REPORTER = "test/e2e/risk-signal-reporter.ts"; 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 PUBLICATION_REQUIRED_CONDITION = "${{ steps.publication_mode.outputs.required == '1' }}"; +const PUBLICATION_REUSE_CONDITION = "${{ steps.publication_mode.outputs.reuse == '1' }}"; +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", " ;;", - " NVIDIA/NemoClaw:refs/heads/main:workflow_dispatch:controller)", - " required=1", + " NVIDIA/NemoClaw:refs/heads/*:workflow_dispatch:controller)", + " required=0", + " reuse=1", " ;;", " *)", ' echo "::error::base-image publication mode is not trusted" >&2', @@ -48,6 +53,7 @@ const PUBLICATION_CLASSIFIER_SCRIPT = " ;;", "esac", 'printf \'required=%s\\n\' "${required}" >> "${GITHUB_OUTPUT}"', + 'printf \'reuse=%s\\n\' "${reuse}" >> "${GITHUB_OUTPUT}"', ].join("\n") + "\n"; const ISSUE_API_REFERENCE = /\bgithub\.rest\.issues\b/u; const ISSUE_MUTATION_BEYOND_COMMENT = @@ -321,7 +327,7 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow const authSource = String(authentication.run ?? ""); for (const fragment of [ '"$WORKFLOW_EVENT" == "workflow_dispatch"', - '"$WORKFLOW_REF" == "refs/heads/main"', + '"$WORKFLOW_REF" == refs/heads/*', '"$PR_NUMBER" =~ ^[1-9][0-9]*$', '"$CHECKOUT_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$', '"$CHECKOUT_SHA" =~ ^[a-f0-9]{40}$', @@ -429,7 +435,7 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow '"$WORKFLOW_REPOSITORY" == "NVIDIA/NemoClaw"', '"$NVIDIA_OWNED" == "true"', '"$EVENT_NAME" == "workflow_dispatch"', - '"$REF" == "refs/heads/main"', + '"$REF" == refs/heads/*', '"$CHECKOUT_SHA" =~ ^[a-f0-9]{40}$', '"$WORKFLOW_SHA" =~ ^[a-f0-9]{40}$', '"$EXPECTED_WORKFLOW_SHA" == "$WORKFLOW_SHA"', @@ -476,8 +482,8 @@ function validateManualPrDispatch(errors: string[], workflow: OperationsWorkflow const trustedPublicationCheckout = jobName === "base-image-publication" && step.name === "Check out trusted E2E workflow" && - step.if === PUBLICATION_REQUIRED_CONDITION && - step.with?.ref === "${{ github.sha }}"; + step.if === PUBLICATION_REQUIRED_OR_REUSE_CONDITION && + step.with?.ref === "${{ inputs.checkout_sha || github.sha }}"; const trustedManagedImageRuntimeCheckout = jobName === "managed-image-protected-runtime" && step.name === "Checkout trusted protected runtime qualification" && @@ -585,8 +591,10 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): "runs-on": "ubuntu-latest", "timeout-minutes": 55, outputs: { - dcode_base_contract: "${{ steps.validate_dcode_base.outputs.contract }}", - dcode_base_ref: "${{ steps.validate_dcode_base.outputs.base_ref }}", + 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 }}", }, permissions: { actions: "read", @@ -607,17 +615,17 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): }, { name: "Check out trusted E2E workflow", - if: PUBLICATION_REQUIRED_CONDITION, + if: PUBLICATION_REQUIRED_OR_REUSE_CONDITION, uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", with: { - ref: "${{ github.sha }}", + ref: "${{ inputs.checkout_sha || github.sha }}", "fetch-depth": 0, "persist-credentials": false, }, }, { name: "Set up Node for publication verification", - if: PUBLICATION_REQUIRED_CONDITION, + if: PUBLICATION_REQUIRED_OR_REUSE_CONDITION, uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", with: { "node-version": 22, @@ -628,10 +636,17 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): name: "Verify applicable base-image publication", if: PUBLICATION_REQUIRED_CONDITION, env: { - EXPECTED_SHA: "${{ github.sha }}", + EXPECTED_SHA: "${{ inputs.checkout_sha || github.sha }}", GITHUB_TOKEN: "${{ github.token }}", }, - run: "node --experimental-strip-types --no-warnings tools/e2e/base-image-publication.mts --wait-seconds 3000 --poll-seconds 30", + 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", + "", + ].join("\n"), }, { name: "Download immutable Deep Agents Code base contract", @@ -644,6 +659,17 @@ 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, + env: { + GITHUB_TOKEN: "${{ github.token }}", + PUBLICATION_HEAD_SHA: "e38db201413b457614904187377ed9fd002d281d", + PUBLICATION_RUN_ATTEMPT: "1", + PUBLICATION_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", @@ -655,6 +681,17 @@ export function validateBaseImagePublicationGate(workflow: OperationsWorkflow): }, 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: PUBLICATION_REUSE_CONDITION, + env: { + PUBLICATION_HEAD_SHA: "e38db201413b457614904187377ed9fd002d281d", + PUBLICATION_RUN_ATTEMPT: "1", + PUBLICATION_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"', + }, ], }; diff --git a/tools/e2e/standard-profile-workflow-boundary.mts b/tools/e2e/standard-profile-workflow-boundary.mts index 98f593fd26f..55da34c0179 100644 --- a/tools/e2e/standard-profile-workflow-boundary.mts +++ b/tools/e2e/standard-profile-workflow-boundary.mts @@ -27,7 +27,7 @@ const PROFILE_WORKFLOW = "./.github/workflows/e2e-standard-profile.yaml"; const CHECKOUT = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"; const EXECUTION_PLAN_SHELL = "/bin/bash --noprofile --norc -e -o pipefail {0}"; const TRUSTED_CALLER_CREDENTIAL_PREDICATE = - "github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true')"; + "github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true')"; const guardedCallerSecret = (name: string): string => `\${{ ${TRUSTED_CALLER_CREDENTIAL_PREDICATE} && secrets.${name} || '' }}`; const SKILL_AGENT_UPLOAD_PATH = `${[ @@ -158,6 +158,7 @@ function validateProfileCallers(errors: string[], workflow: WorkflowRecord): voi risk_signal_correlation_id: "${{ github.event_name == 'workflow_dispatch' && inputs.checkout_sha != '' && inputs.correlation_id || '' }}", cli_artifact_provenance: "${{ needs.generate-matrix.outputs.cli_artifact_provenance }}", + managed_image_catalog: "${{ needs.generate-matrix.outputs.managed_image_catalog }}", credential_boundary: contract.credentialBoundary, catalogue_id: "${{ matrix.id }}", target_id: "${{ matrix.target_id }}", @@ -179,7 +180,7 @@ function validateProfileCallers(errors: string[], workflow: WorkflowRecord): voi shard: "${{ matrix.shard }}", artifact_layout: "${{ matrix.artifact_layout }}", trusted_main: - "${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true') }}", + "${{ 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') }}", })) { if (withInputs[name] !== expected) { errors.push(`${contract.job} must pass ${name} from the catalogue matrix`); @@ -205,6 +206,7 @@ function validateProfileWorkflow(errors: string[], profile: WorkflowRecord): voi risk_signal_expected_sha: "string", risk_signal_correlation_id: "string", cli_artifact_provenance: "string", + managed_image_catalog: "string", credential_boundary: "string", catalogue_id: "string", target_id: "string", @@ -295,6 +297,7 @@ function validateProfileWorkflow(errors: string[], profile: WorkflowRecord): voi "Install target host dependencies", "Prepare E2E workspace", "Restore exact-commit CLI artifact", + "Materialize temporary managed-image catalog", "Install reviewed cloudflared", "Add swap for Hermes image rebuild", "Initialize runner comparison telemetry", @@ -389,6 +392,7 @@ function validateProfileWorkflow(errors: string[], profile: WorkflowRecord): voi checkout?.uses !== CHECKOUT || checkoutWith.repository !== "${{ inputs.candidate_repository }}" || checkoutWith.ref !== "${{ inputs.candidate_sha }}" || + checkoutWith["fetch-depth"] !== 0 || checkoutWith["persist-credentials"] !== false || workflowSteps.indexOf(checkout ?? {}) !== 2 ) { @@ -444,6 +448,40 @@ function validateProfileWorkflow(errors: string[], profile: WorkflowRecord): voi ) { errors.push("standard E2E profile must restore the planned exact-commit CLI artifact"); } + const managedCatalog = requireStep( + errors, + workflowSteps, + "Materialize temporary managed-image catalog", + ); + const managedCatalogRun = String(managedCatalog?.run ?? ""); + if ( + managedCatalog?.if !== "${{ inputs.managed_image_catalog != '' }}" || + managedCatalog.shell !== EXECUTION_PLAN_SHELL || + !isDeepStrictEqual(record(managedCatalog.env), { + CANDIDATE_SHA: "${{ inputs.candidate_sha }}", + MANAGED_IMAGE_CATALOG: "${{ inputs.managed_image_catalog }}", + RESTORE_CLI: "${{ inputs.restore_cli && 'true' || 'false' }}", + }) || + !managedCatalogRun.includes(".source.revision == $revision") || + !managedCatalogRun.includes("[.[].source.release] | unique | length") || + !managedCatalogRun.includes("[.[].source.cohort] | unique | length") || + !managedCatalogRun.includes('[[ "$RESTORE_CLI" == "true" ]]') || + !managedCatalogRun.includes(".source.release == $release") || + !managedCatalogRun.includes( + "managed-image catalog source identity does not match the candidate", + ) || + !managedCatalogRun.includes( + "managed-image catalog release does not match the restored CLI", + ) || + !managedCatalogRun.includes("NEMOCLAW_E2E_MANAGED_IMAGE_CATALOG") || + managedCatalogRun.includes("NEMOCLAW_E2E_EXACT_RELEASE") || + managedCatalogRun.includes(".source.release = $release") || + workflowSteps.indexOf(managedCatalog ?? {}) !== workflowSteps.indexOf(restore ?? {}) + 1 + ) { + errors.push( + "standard E2E profile must materialize only the exact-candidate managed-image catalog", + ); + } const cloudflared = requireStep(errors, workflowSteps, "Install reviewed cloudflared"); const cloudflaredRun = String(cloudflared?.run ?? ""); if ( @@ -461,14 +499,16 @@ function validateProfileWorkflow(errors: string[], profile: WorkflowRecord): voi !cloudflaredRun.includes('dpkg-deb -f "${cloudflared_deb}" Package') || !cloudflaredRun.includes('"${architecture}" != "amd64"') || cloudflaredRun.includes("command -v cloudflared") || - workflowSteps.indexOf(cloudflared ?? {}) !== workflowSteps.indexOf(restore ?? {}) + 1 + workflowSteps.indexOf(cloudflared ?? {}) !== workflowSteps.indexOf(managedCatalog ?? {}) + 1 ) { errors.push("standard E2E profile must install only the reviewed cloudflared package"); } const rebuildSwap = requireStep(errors, workflowSteps, "Add swap for Hermes image rebuild"); const rebuildSwapRun = String(rebuildSwap?.run ?? ""); const rebuildSwapFragments = [ - '[[ "${REPOSITORY}" != "NVIDIA/NemoClaw" || "${REF}" != "refs/heads/main" ]]', + '[[ "${REPOSITORY}" != "NVIDIA/NemoClaw" ]]', + '[[ "${EVENT_NAME}" == "push" && "${REF}" != "refs/heads/main" ]]', + '[[ "${EVENT_NAME}" == "workflow_dispatch" && "${REF}" != refs/heads/* ]]', '[[ "${RUNNER_ENVIRONMENT_KIND}" != "github-hosted"', 'fail "refusing unexpected pre-existing rebuild swap path"', "required_disk_bytes=$((swap_file_bytes + reserve_bytes))", diff --git a/tools/e2e/target-catalogue.mts b/tools/e2e/target-catalogue.mts index 41950211979..46916a88464 100644 --- a/tools/e2e/target-catalogue.mts +++ b/tools/e2e/target-catalogue.mts @@ -45,6 +45,9 @@ export type E2eHostPreparation = (typeof E2E_HOST_PREPARATIONS)[number]; export const E2E_ARTIFACT_LAYOUTS = ["target-shard", "flat-shard"] as const; export type E2eArtifactLayout = (typeof E2E_ARTIFACT_LAYOUTS)[number]; +export const E2E_OPTIONAL_CREDENTIALS = ["BRAVE_API_KEY"] as const; +export type E2eOptionalCredential = (typeof E2E_OPTIONAL_CREDENTIALS)[number]; + export interface E2eCatalogueTarget { id: string; targetId: string; @@ -69,6 +72,7 @@ export interface E2eCatalogueTarget { runnerComparison: boolean; runnerPressure: boolean; compatibleApiKey: boolean; + requiredOptionalCredentials: readonly E2eOptionalCredential[]; prAdvisorSelectable: boolean; shard: string; artifactLayout: E2eArtifactLayout; @@ -121,6 +125,7 @@ type TargetOptions = Omit< | "runnerComparison" | "runnerPressure" | "compatibleApiKey" + | "requiredOptionalCredentials" | "prAdvisorSelectable" | "shard" | "artifactLayout" @@ -140,6 +145,7 @@ type TargetOptions = Omit< runnerComparison?: boolean; runnerPressure?: boolean; compatibleApiKey?: boolean; + requiredOptionalCredentials?: readonly E2eOptionalCredential[]; prAdvisorSelectable?: boolean; shard?: string; artifactLayout?: E2eArtifactLayout; @@ -164,6 +170,7 @@ function target(id: string, options: TargetOptions): E2eCatalogueTarget { runnerComparison = false, runnerPressure = false, compatibleApiKey = false, + requiredOptionalCredentials = [], prAdvisorSelectable = false, shard = "default", artifactLayout = "target-shard", @@ -189,6 +196,7 @@ function target(id: string, options: TargetOptions): E2eCatalogueTarget { runnerComparison, runnerPressure, compatibleApiKey, + requiredOptionalCredentials, prAdvisorSelectable, shard, artifactLayout, @@ -213,6 +221,7 @@ function commonEgressTarget(options: { hermes?: boolean; owningPaths?: readonly string[]; profile?: E2eExecutionProfile; + requiredOptionalCredentials?: readonly E2eOptionalCredential[]; runnerComparison?: boolean; selector: string; shard: string; @@ -223,6 +232,7 @@ function commonEgressTarget(options: { agentRuntime: options.hermes ? "hermes" : "openclaw", environmentOrInferenceEndpoint: options.environmentOrInferenceEndpoint, profile: options.profile ?? "brave-nvidia-inference", + requiredOptionalCredentials: options.requiredOptionalCredentials, testFile: "test/e2e/live/common-egress-agent.test.ts", timeoutMinutes: 60, installMode: "credential-free", @@ -475,6 +485,7 @@ export const E2E_TARGET_CATALOGUE: readonly E2eCatalogueTarget[] = [ agentRuntime: "openclaw", environmentOrInferenceEndpoint: "Ubuntu; NVIDIA hosted inference and Brave Search", profile: "brave-nvidia-inference", + requiredOptionalCredentials: ["BRAVE_API_KEY"], timeoutMinutes: 45, installMode: "authenticated", installNonInteractive: true, @@ -584,6 +595,7 @@ export const E2E_TARGET_CATALOGUE: readonly E2eCatalogueTarget[] = [ environmentOrInferenceEndpoint: "Ubuntu; NVIDIA hosted inference and public weather endpoint", shard: "openclaw-balanced-weather", selector: "^common-egress.+C1.+$", + requiredOptionalCredentials: ["BRAVE_API_KEY"], }), commonEgressTarget({ displayName: "Networking: OpenClaw reaches a public reference through open egress", @@ -1568,6 +1580,15 @@ export function validateE2eTargetCatalogue( ) { throw new Error(`E2E target ${entry.id} has invalid or duplicate host packages`); } + if ( + new Set(entry.requiredOptionalCredentials).size !== + entry.requiredOptionalCredentials.length || + entry.requiredOptionalCredentials.some( + (credential) => !E2E_OPTIONAL_CREDENTIALS.includes(credential), + ) + ) { + throw new Error(`E2E target ${entry.id} has invalid optional credential requirements`); + } if (entry.selector !== undefined && !SELECTOR_PATTERN.test(entry.selector)) { throw new Error(`E2E target ${entry.id} has an invalid test selector`); } diff --git a/tools/e2e/trusted-hermes-swap-workflow-boundary.mts b/tools/e2e/trusted-hermes-swap-workflow-boundary.mts index 2e05dde5358..ca841803cc5 100644 --- a/tools/e2e/trusted-hermes-swap-workflow-boundary.mts +++ b/tools/e2e/trusted-hermes-swap-workflow-boundary.mts @@ -19,7 +19,7 @@ export const TRUSTED_HERMES_SWAP_STEP_NAME = "Provision trusted Hermes E2E swap" export const TRUSTED_HERMES_SWAP_STEP_ID = "trusted_hermes_swap"; const TRUSTED_HERMES_SWAP_IF = - "github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')"; + "github.repository == 'NVIDIA/NemoClaw' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))"; const TRUSTED_HERMES_E2E_SELECTION = `(${selectorsForCanonicalE2eId("hermes-e2e") .flatMap((selector) => [ `contains(format(',{0},', inputs.jobs), ',${selector},')`, @@ -60,12 +60,18 @@ export const TRUSTED_HERMES_SWAP_SCRIPT = [ " exit 1", "}", "", - 'if [[ "${REPOSITORY}" != "NVIDIA/NemoClaw" || "${REF}" != "refs/heads/main" ]]; then', - ' fail "workflow must run from NVIDIA/NemoClaw main"', + 'if [[ "${REPOSITORY}" != "NVIDIA/NemoClaw" ]]; then', + ' fail "workflow must run from NVIDIA/NemoClaw"', "fi", 'if [[ "${EVENT_NAME}" != "push" && "${EVENT_NAME}" != "workflow_dispatch" ]]; then', ' fail "workflow event must be push or workflow_dispatch"', "fi", + 'if [[ "${EVENT_NAME}" == "push" && "${REF}" != "refs/heads/main" ]]; then', + ' fail "push workflow must run from NVIDIA/NemoClaw main"', + "fi", + 'if [[ "${EVENT_NAME}" == "workflow_dispatch" && "${REF}" != refs/heads/* ]]; then', + ' fail "manual workflow must run from an NVIDIA/NemoClaw branch"', + "fi", "# PR E2E mode: maintainer-dispatched PR commit.", 'if [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${CHECKOUT_SHA}" ]]; then', ' if [[ ! "${CHECKOUT_SHA}" =~ ^[0-9a-f]{40}$ ]]; then', diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index 04497c88f5f..b3c3c401a9f 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -217,11 +217,11 @@ const DOCKER_HUB_CLEANUP_KEYS = ["if", "name", "run", "shell"]; // The general E2E workflow runs on push/manual dispatch. Its event set is // intentionally distinct from the reusable image workflow's push/manual boundary. const TRUSTED_DOCKER_HUB_PREDICATE = - "github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (inputs.checkout_sha == '' || needs.generate-matrix.outputs.e2e_credentials_allowed == 'true')"; + "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')"; const GUARDED_DOCKER_HUB_AUTH_REQUIRED = `\${{ ${TRUSTED_DOCKER_HUB_PREDICATE} && '1' || '0' }}`; 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.ref == 'refs/heads/main' && 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_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 RUNNER_ROUTING_OUTPUT = "${{ steps.runner_routing.outputs.runner_routing }}"; const RUNNER_ROUTING_STEP_NAME = "Build trusted larger-runner routing"; const RUNNER_ROUTING_SCRIPT = [ @@ -2566,6 +2566,43 @@ 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[] = []; @@ -2790,6 +2827,12 @@ 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"); diff --git a/tools/e2e/workflow-plan.mts b/tools/e2e/workflow-plan.mts index 0984c410837..5e6b8e21445 100644 --- a/tools/e2e/workflow-plan.mts +++ b/tools/e2e/workflow-plan.mts @@ -27,10 +27,12 @@ import { catalogueTarget, catalogueTargetsForChangedFiles, E2E_EXECUTION_PROFILES, + E2E_OPTIONAL_CREDENTIALS, E2E_TARGET_CATALOGUE, type E2eCatalogueMatrixRow, type E2eCatalogueTarget, type E2eExecutionProfile, + type E2eOptionalCredential, isPrCandidateCatalogueTarget, pathMatches, } from "./target-catalogue.mts"; @@ -817,6 +819,27 @@ export function withoutCredentialedCatalogueProfiles(plan: E2eWorkflowPlan): E2e }; } +export function withoutUnavailableOptionalCredentialTargets( + plan: E2eWorkflowPlan, + availableCredentials: ReadonlySet, +): E2eWorkflowPlan { + const catalogueMatrices = Object.fromEntries( + E2E_EXECUTION_PROFILES.map((profile) => [ + profile, + plan.catalogueMatrices[profile].filter((row) => + catalogueTarget(row.id).requiredOptionalCredentials.every((credential) => + availableCredentials.has(credential), + ), + ), + ]), + ) as Record; + const { coverageMatrix: _coverageMatrix, ...planWithoutCoverage } = plan; + return withCoverageMatrix( + { ...planWithoutCoverage, catalogueMatrices }, + readFreeStandingJobsInventory(), + ); +} + function restrictUnauthorizedCandidatePlan( plan: E2eWorkflowPlan, hasPlannerSelectors: boolean, @@ -930,12 +953,20 @@ export function writeE2eWorkflowPlanCiOutput( controllerMap.retiredSelectorSelected && !hasPlannerSelectors ? emptyE2eWorkflowPlan() : buildE2eWorkflowPlan(plannerSelectors, { changedFiles }); + const availableOptionalCredentials = new Set( + E2E_OPTIONAL_CREDENTIALS.filter( + (credential) => environment[`NEMOCLAW_E2E_${credential}_AVAILABLE`] !== "false", + ), + ); + const availabilityScopedPlan = hasPlannerSelectors + ? planned + : withoutUnavailableOptionalCredentialTargets(planned, availableOptionalCredentials); const candidateRevision = COMMIT_SHA_PATTERN.test(environment.NEMOCLAW_E2E_EXPECTED_SHA ?? ""); const credentialsAllowed = environment.NEMOCLAW_E2E_CREDENTIALS_ALLOWED === "true"; const plan = validateE2eWorkflowPlan( candidateRevision && !credentialsAllowed - ? restrictUnauthorizedCandidatePlan(planned, hasPlannerSelectors) - : planned, + ? restrictUnauthorizedCandidatePlan(availabilityScopedPlan, hasPlannerSelectors) + : availabilityScopedPlan, ); const expectedHermes = candidateRevision && !credentialsAllowed && !hasPlannerSelectors diff --git a/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle b/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle index 94ccc3389ab..3a55a288561 100644 --- a/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle +++ b/tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle @@ -1,11 +1,12 @@ -var __create=Object.create;var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __getOwnPropNames=Object.getOwnPropertyNames;var __getProtoOf=Object.getPrototypeOf;var __hasOwnProp=Object.prototype.hasOwnProperty;var __export=(target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:true})};var __copyProps=(to,from,except,desc)=>{if(from&&typeof from==="object"||typeof from==="function"){for(let key of __getOwnPropNames(from))if(!__hasOwnProp.call(to,key)&&key!==except)__defProp(to,key,{get:()=>from[key],enumerable:!(desc=__getOwnPropDesc(from,key))||desc.enumerable})}return to};var __toESM=(mod,isNodeMode,target)=>(target=mod!=null?__create(__getProtoOf(mod)):{},__copyProps(isNodeMode||!mod||!mod.__esModule?__defProp(target,"default",{value:mod,enumerable:true}):target,mod));var __toCommonJS=mod=>__copyProps(__defProp({},"__esModule",{value:true}),mod);var image_runtime_exports={};__export(image_runtime_exports,{applyManagedBootstrapEnvelope:()=>applyManagedBootstrapEnvelope,main:()=>main2,managedBootstrapEnvelopeClaimPaths:()=>managedBootstrapEnvelopeClaimPaths,readManagedBootstrapEnvelope:()=>readManagedBootstrapEnvelope,recoverManagedBootstrapEnvelopeClaim:()=>recoverManagedBootstrapEnvelopeClaim,verifyManagedBootstrapImageCompletion:()=>verifyManagedBootstrapImageCompletion,waitForManagedBootstrapImageCompletion:()=>waitForManagedBootstrapImageCompletion});module.exports=__toCommonJS(image_runtime_exports);var import_node_fs4=__toESM(require("node:fs"));var import_node_path4=__toESM(require("node:path"));var import_node_child_process=require("node:child_process");var import_node_crypto6=require("node:crypto");var import_node_fs3=__toESM(require("node:fs"));var import_node_path3=__toESM(require("node:path"));var import_node_buffer2=require("node:buffer");function isObjectRecord(value){return typeof value==="object"&&value!==null&&!Array.isArray(value)}var ChannelManifestRegistry=class{manifests=new Map;constructor(manifests=[]){for(const manifest of manifests){this.register(manifest)}}register(manifest){if(this.manifests.has(manifest.id)){throw new Error(`Duplicate channel manifest id '${manifest.id}'`)}this.manifests.set(manifest.id,manifest);return this}get(channelId){return this.manifests.get(channelId)}list(){return Array.from(this.manifests.values())}listAvailable(ctx={}){const supportedChannelIds=Array.isArray(ctx.supportedChannelIds)?new Set(ctx.supportedChannelIds):null;return this.list().filter(manifest=>{if(ctx.agent&&!manifest.supportedAgents.includes(ctx.agent)){return false}if(supportedChannelIds&&!supportedChannelIds.has(manifest.id)){return false}return true})}};function createChannelManifestRegistry(manifests=[]){return new ChannelManifestRegistry(manifests)}var discordManifest={schemaVersion:1,id:"discord",displayName:"Discord",description:"Discord bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"DISCORD_BOT_TOKEN",prompt:{label:"Discord Bot Token",help:"Discord Developer Portal \u2192 Applications \u2192 Bot \u2192 Reset/Copy Token."}},{id:"serverId",kind:"config",required:false,envKey:"DISCORD_SERVER_ID",statePath:"discordGuilds.serverId",prompt:{label:"Discord Server ID (for guild workspace access)",help:"Enable Developer Mode in Discord, then right-click your server and copy the Server ID.",emptyValueMessage:"guild channels stay disabled"}},{id:"requireMention",kind:"config",required:false,envKey:"DISCORD_REQUIRE_MENTION",statePath:"discordGuilds.requireMention",promptWhenInput:"serverId",validValues:["0","1"],defaultValue:"1",prompt:{label:"Discord mention mode",help:"Choose whether the bot should reply only when @mentioned or to all messages in this server."}},{id:"userId",kind:"config",required:false,envKey:"DISCORD_USER_ID",statePath:"discordGuilds.userIds",promptWhenInput:"serverId",prompt:{label:"Discord User ID (optional guild allowlist)",help:"Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.",emptyValueMessage:"any member in the configured server can message the bot"}}],credentials:[{id:"discordBotToken",sourceInput:"botToken",providerName:"{sandboxName}-discord-bridge",providerEnvKey:"DISCORD_BOT_TOKEN",placeholder:"openshell:resolve:env:DISCORD_BOT_TOKEN"}],policyPresets:[{name:"discord",validationWarningLines:["For Discord preset validation, do not use curl as the success signal:","curl is not in the preset binary allowlist, so curl probes can fail even","when the policy is working. Use Node HTTPS against","https://discord.com/api/v10/gateway or validate the configured",'messaging bridge/gateway path. DNS-only checks such as dns.resolve("gateway.discord.gg")',"can also be inconclusive behind a proxy."]}],render:[{id:"discord-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.discord",value:{enabled:true,accounts:{default:{token:"{{credential.discordBotToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},proxy:"{{discordProxyUrl}}",dmPolicy:"{{discord.allowedUsers.dmPolicy}}",allowFrom:"{{discord.allowedUsers.values}}"}}}}},{id:"discord-openclaw-guilds",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{discord.hasGuilds}}",fragment:{path:"channels.discord",value:{groupPolicy:"allowlist",guilds:"{{discord.guilds}}"}}},{id:"discord-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.discord",value:{enabled:true}}},{id:"discord-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["DISCORD_BOT_TOKEN={{credential.discordBotToken.placeholder}}","NEMOCLAW_DISCORD_GUILD_IDS={{discord.guildIds.csv}}","DISCORD_ALLOWED_USERS={{discord.allowedUsers.csv}}","DISCORD_ALLOW_ALL_USERS={{discord.allowAllUsers}}"]},{id:"discord-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"discord",value:{require_mention:"{{discord.requireMention}}",free_response_channels:"",allowed_channels:"",auto_thread:true,reactions:true,channel_prompts:{}}}},{id:"discord-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.discord",value:{enabled:true}}}],runtime:{openclaw:{channelName:"discord",visibility:{configKeys:["discord"],logPatterns:["discord"]}}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/discord@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz"},required:true}],hooks:[{id:"discord-openclaw-bridge-health",phase:"health-check",handler:"discord.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"discord-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"discord-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"serverId",kind:"config"},{id:"requireMention",kind:"config"},{id:"userId",kind:"config"}]}]};var googlechatManifest={schemaVersion:1,id:"googlechat",displayName:"Google Chat",description:"Google Chat (Chat API) bot messaging (experimental)",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"serviceAccount",kind:"secret",required:true,envKey:"GOOGLECHAT_SERVICE_ACCOUNT",maskCap:40,formatHint:"Paste the entire service-account JSON key on one line (minified) \u2014 the whole downloaded JSON file.",maxTokenAttempts:3,prompt:{label:"Google Chat service account JSON",help:["\u2503 GOOGLE CHAT \u2014 service account key","\u2503","\u2503 Google Cloud Console \u2192 IAM & Admin \u2192 Service Accounts","\u2503 \u2192 your bot's SA \u2192 Keys \u2192 Add key \u2192 Create new key \u2192 JSON","\u2503","\u2503 A .json file downloads. Paste its contents below as ONE line (minified).",""].join("\n")}},{id:"audienceType",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE_TYPE",statePath:"googlechatConfig.audienceType",validValues:["app-url","project-number"],defaultValue:"app-url"},{id:"audience",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE",statePath:"googlechatConfig.audience",prompt:{label:"Google Chat webhook audience",help:"Usually filled automatically from the public tunnel URL. For audienceType 'project-number', enter your GCP project number instead.",emptyValueMessage:"inbound webhook verification will be unconfigured"}},{id:"appPrincipal",kind:"config",required:false,envKey:"GOOGLECHAT_APP_PRINCIPAL",statePath:"googlechatConfig.appPrincipal",formatPattern:"^[0-9]{6,32}$",formatHint:"appPrincipal is the add-on's numeric OAuth client ID (uniqueId, ~21 digits), not an email.",prompt:{label:"Google Chat appPrincipal",help:[" Workspace account \u2192 leave blank, done."," Personal Gmail \u2192 needs the add-on's ~21-digit ID (not an email), stable across rebuilds.",""," If you already know it, paste it at the prompt and you're done."," If not, leave it blank \u2014 the first DM reveals it once the sandbox is live:",""," 1. Watch the gateway log:",' nemoclaw logs --follow | grep "unexpected add-on principal"'," 2. DM the bot once \u2014 it won't reply yet, that's expected. The log prints:"," unexpected add-on principal: "," 3. Save that and rebuild:"," GOOGLECHAT_APP_PRINCIPAL= nemoclaw channels add googlechat"," nemoclaw rebuild --yes"].join("\n"),emptyValueMessage:"Workspace accounts do not need it; personal accounts must set it later"}},{id:"allowFrom",kind:"config",required:false,envKey:"GOOGLECHAT_ALLOWED_USERS",statePath:"allowedIds.googlechat",prompt:{label:"Google Chat DM allowlist (comma-separated)",help:["Optional: restrict who can DM the bot."," OpenClaw: users/NNN (emails ignored)"," Hermes: email (users/NNN ignored)"," Blank: pairing mode (recommended) \u2014 OpenClaw's pairing reply shows your users/NNN"," Filling this switches DM policy to allowlist \u2014 a wrong-form entry is dropped silently, with no pairing code."].join("\n"),emptyValueMessage:"bot will require manual pairing"}},{id:"projectId",kind:"config",required:false,envKey:"GOOGLE_CHAT_PROJECT_ID",statePath:"googlechatConfig.projectId",prompt:{label:"Google Chat GCP project ID (Hermes Pub/Sub pull)",help:"The Google Cloud project that owns the Pub/Sub subscription Hermes pulls Chat events from. OpenClaw ignores this.",emptyValueMessage:"required for the Hermes Google Chat channel"}},{id:"subscriptionName",kind:"config",required:false,envKey:"GOOGLE_CHAT_SUBSCRIPTION_NAME",statePath:"googlechatConfig.subscriptionName",prompt:{label:"Google Chat Pub/Sub subscription (projects/

/subscriptions/)",help:["The pull subscription bound to the Chat events topic. Hermes pulls from it over the Pub/Sub REST API; the gateway-minted token is scoped to both chat.bot and pubsub."," Its topic must grant roles/pubsub.publisher to the app's push account:"," Interactive features service-@gcp-sa-gsuiteaddons.iam.gserviceaccount.com"," Classic bot chat-api-push@system.gserviceaccount.com"," Shown at Chat API \u2192 Configuration \u2192 Connection settings"," Missing it channel connects, no event arrives, Chat says the bot is not responding"].join("\n"),emptyValueMessage:"required for the Hermes Google Chat channel"}}],credentials:[],policyPresets:[{name:"googlechat",policyKeys:["googlechat"],agentPolicyKeys:{hermes:["googlechat_hermes"]}}],render:[{id:"googlechat-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.googlechat",value:{enabled:true,serviceAccountFile:"/nonexistent/googlechat-gateway-minted-no-service-account-file",audienceType:"{{googlechatConfig.audienceType}}",audience:"{{googlechatConfig.audience}}",appPrincipal:"{{googlechatConfig.appPrincipal}}",webhookPath:"/googlechat",healthMonitor:{enabled:false},dm:{policy:"{{allowedIds.googlechat.dmPolicy}}",allowFrom:"{{allowedIds.googlechat.values}}"}}}},{id:"googlechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.googlechat",value:{enabled:true}}},{id:"googlechat-openclaw-gateway-reload-off",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"gateway.reload",value:{mode:"off"}}},{id:"googlechat-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["GOOGLE_CHAT_PROJECT_ID={{googlechatConfig.projectId}}","GOOGLE_CHAT_SUBSCRIPTION_NAME={{googlechatConfig.subscriptionName}}","GOOGLE_CHAT_ALLOWED_USERS={{allowedIds.googlechat.csv}}"]},{id:"googlechat-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.google_chat",value:{enabled:true}}}],runtime:{openclaw:{channelName:"googlechat",visibility:{configKeys:["googlechat"],logPatterns:["googlechat"]},nodePreloads:[{module:"googlechat-trusted-proxy-fetch",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat trusted-proxy-fetch patch (route googleapis via trusted env proxy)",installedMessage:"[channels] Google Chat trusted-proxy-fetch patch installed (NODE_OPTIONS updated)"},{module:"googlechat-outbound-auth",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat outbound-auth patch (gateway-minted bearer)",installedMessage:"[channels] Google Chat outbound-auth patch installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"-----BEGIN (?:RSA )?PRIVATE KEY-----",message:"[SECURITY] Google Chat service account private key leaked into {path} - refusing to serve",exitCode:78}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/googlechat@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-Dv0xOmcxAThEr6hoK+ioofHNu18hfbIceQrEHX3AHZPpOUiTJvToVpA5eX87NQINewwfSJf0gVhE6kSbSk2Aew=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/googlechat/-/googlechat-2026.7.1.tgz"},required:true},{id:"hermesGooglePubsubPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-cloud-pubsub==2.39.0",required:true},{id:"hermesGoogleApiClientPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-api-python-client==2.194.0",required:true},{id:"hermesGoogleAuthPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-auth==2.55.1",required:true}],hooks:[{id:"googlechat-tunnel-audience-gate",phase:"enroll",handler:"googlechat.tunnelAudienceGate",agents:["openclaw"],inputs:["audienceType","audience"],outputs:[{id:"audience",kind:"config"}],onFailure:"skip-channel"},{id:"googlechat-service-account",phase:"enroll",handler:"googlechat.tokenPaste",outputs:[{id:"serviceAccount",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"googlechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowFrom",kind:"config"}]},{id:"googlechat-openclaw-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["openclaw"],outputs:[{id:"appPrincipal",kind:"config"}]},{id:"googlechat-hermes-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["hermes"],outputs:[{id:"projectId",kind:"config"},{id:"subscriptionName",kind:"config"}]}]};var slackRuntimeEnvAliases=[{envKey:"SLACK_BOT_TOKEN",match:"^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$",value:"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",message:"[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias"},{envKey:"SLACK_APP_TOKEN",match:"^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$",value:"xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN",message:"[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias"}];var slackManifest={schemaVersion:1,id:"slack",displayName:"Slack",description:"Slack bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"SLACK_BOT_TOKEN",formatPattern:"^xoxb-[A-Za-z0-9_-]+$",formatHint:"Slack bot tokens start with 'xoxb-' (e.g. xoxb---).",prompt:{label:"Slack Bot Token",help:"Slack API \u2192 Your Apps \u2192 OAuth & Permissions \u2192 Bot User OAuth Token (xoxb-...)."}},{id:"appToken",kind:"secret",required:true,envKey:"SLACK_APP_TOKEN",formatPattern:"^xapp-[A-Za-z0-9_-]+$",formatHint:"Slack app tokens start with 'xapp-' (e.g. xapp----).",prompt:{label:"Slack App Token (Socket Mode)",help:"Slack API \u2192 Your Apps \u2192 Basic Information \u2192 App-Level Tokens (xapp-...)."}},{id:"allowedUsers",kind:"config",required:false,envKey:"SLACK_ALLOWED_USERS",statePath:"allowedIds.slack",prompt:{label:"Slack Member IDs (comma-separated allowlist)",help:"In Slack, open each allowed human user's profile -> More -> Copy member ID. Enter one or more comma-separated member IDs, not the app or bot user ID. Member IDs look like U01ABC2DEF3.",emptyValueMessage:"bot will require manual pairing"}},{id:"allowedChannels",kind:"config",required:false,envKey:"SLACK_ALLOWED_CHANNELS",statePath:"slackConfig.allowedChannels",prompt:{label:"Slack Channel IDs (comma-separated allowlist)",help:"Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.",emptyValueMessage:"channel @mentions stay unrestricted by channel ID"}}],credentials:[{id:"slackBotToken",sourceInput:"botToken",providerName:"{sandboxName}-slack-bridge",providerEnvKey:"SLACK_BOT_TOKEN",placeholder:"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",primary:true},{id:"slackAppToken",sourceInput:"appToken",providerName:"{sandboxName}-slack-app",providerEnvKey:"SLACK_APP_TOKEN",placeholder:"xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"}],policyPresets:[{name:"slack",requiredAtCreate:true}],render:[{id:"slack-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.slack",value:{enabled:true,accounts:{default:{botToken:"{{credential.slackBotToken.placeholder}}",appToken:"{{credential.slackAppToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},dmPolicy:"{{allowedIds.slack.dmPolicy}}",allowFrom:"{{allowedIds.slack.values}}",groupPolicy:"{{allowedIds.slack.groupPolicy}}",channels:"{{allowedIds.slack.channels}}"}}}}},{id:"slack-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.slack",value:{enabled:true}}},{id:"slack-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["SLACK_BOT_TOKEN={{credential.slackBotToken.placeholder}}","SLACK_APP_TOKEN={{credential.slackAppToken.placeholder}}","SLACK_ALLOWED_USERS={{allowedIds.slack.csv}}","SLACK_ALLOWED_CHANNELS={{slackConfig.allowedChannels.csv}}"]},{id:"slack-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.slack",value:{enabled:true,extra:{rich_blocks:true}}}}],runtime:{openclaw:{channelName:"slack",visibility:{configKeys:["slack"],logPatterns:["slack"]},envAliases:slackRuntimeEnvAliases,nodePreloads:[{module:"slack-channel-guard",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Slack channel guard (unhandled-rejection safety net)",installedMessage:"[channels] Slack channel guard installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)",message:"[SECURITY] Slack token leaked into {path} - refusing to serve",exitCode:78}]},hermes:{envAliases:slackRuntimeEnvAliases}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/slack@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz"},required:true}],hooks:[{id:"slack-socket-mode-gateway-conflict",phase:"pre-enable",handler:"slack.socketModeGatewayConflict",onFailure:"abort"},{id:"slack-openclaw-bridge-health",phase:"health-check",handler:"slack.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"slack-socket-mode-gateway-status",phase:"status",handler:"slack.socketModeGatewayStatus",outputs:[{id:"gatewayOverlaps",kind:"status"}]},{id:"slack-status-health",phase:"status",handler:"slack.statusHealth",providesReadiness:true,agents:["openclaw"],outputs:[{id:"channelHealth",kind:"status"}]},{id:"slack-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true},{id:"appToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"slack-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedUsers",kind:"config"},{id:"allowedChannels",kind:"config"}]},{id:"slack-credential-validation",phase:"reachability-check",handler:"slack.validateCredentials",inputs:["botToken","appToken"],onFailure:"skip-channel"}]};var teamsManifest={schemaVersion:1,id:"teams",displayName:"Microsoft Teams",description:"Microsoft Teams bot messaging (experimental)",enrollmentNotes:["Microsoft Teams requires a public HTTPS webhook endpoint at /api/messages; expose the configured Teams webhook port before installing the Teams app.","Use Azure AD object IDs in TEAMS_ALLOWED_USERS so only authorized users can interact with the bot."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"appId",kind:"config",required:true,envKey:"MSTEAMS_APP_ID",statePath:"teamsConfig.appId",prompt:{label:"Microsoft Teams Client ID",help:"Run `teams app create --endpoint https:///api/messages`, then copy CLIENT_ID."}},{id:"clientSecret",kind:"secret",required:true,envKey:"MSTEAMS_APP_PASSWORD",prompt:{label:"Microsoft Teams Client Secret",help:"Use the CLIENT_SECRET printed by `teams app create`. It is shown once; rotate it in Entra ID if it was lost."}},{id:"tenantId",kind:"config",required:true,envKey:"MSTEAMS_TENANT_ID",statePath:"teamsConfig.tenantId",prompt:{label:"Microsoft Teams Tenant ID",help:"Use the TENANT_ID printed by `teams app create` or shown by `teams status --verbose`."}},{id:"allowedUsers",kind:"config",required:false,envKey:"TEAMS_ALLOWED_USERS",statePath:"allowedIds.teams",prompt:{label:"Microsoft Teams AAD Object IDs (comma-separated allowlist)",help:"Recommended: run `teams status --verbose` and enter the Azure AD object IDs allowed to use the bot."}},{id:"webhookPort",kind:"config",required:false,envKey:"MSTEAMS_PORT",statePath:"teamsConfig.webhookPort",defaultValue:"3978",prompt:{label:"Microsoft Teams webhook port",help:"Local bot webhook port to expose publicly. Defaults to 3978 and serves /api/messages."}},{id:"requireMention",kind:"config",required:false,envKey:"TEAMS_REQUIRE_MENTION",statePath:"teamsConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Microsoft Teams mention mode",help:"Controls OpenClaw group and channel behavior only. Direct messages are unaffected."}}],credentials:[{id:"teamsClientSecret",sourceInput:"clientSecret",providerName:"{sandboxName}-teams-bridge",providerEnvKey:"MSTEAMS_APP_PASSWORD",placeholder:"openshell:resolve:env:MSTEAMS_APP_PASSWORD",primary:true}],policyPresets:[{name:"teams",policyKeys:["teams"]}],hostForward:{port:"{{teamsConfig.webhookPort}}",label:"Microsoft Teams webhook"},render:[{id:"teams-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.msteams",value:{enabled:true,appId:"{{teamsConfig.appId}}",appPassword:"{{credential.teamsClientSecret.placeholder}}",tenantId:"{{teamsConfig.tenantId}}",webhook:{port:"{{teamsConfig.webhookPort}}",path:"/api/messages"},healthMonitor:{enabled:false},streaming:{mode:"off"},dmPolicy:"{{allowedIds.teams.dmPolicy}}",allowFrom:"{{allowedIds.teams.values}}",groupPolicy:"open",requireMention:"{{teamsConfig.requireMention}}"}}},{id:"teams-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.msteams",value:{enabled:true}}},{id:"teams-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TEAMS_CLIENT_ID={{teamsConfig.appId}}","TEAMS_CLIENT_SECRET={{credential.teamsClientSecret.placeholder}}","TEAMS_TENANT_ID={{teamsConfig.tenantId}}","TEAMS_ALLOWED_USERS={{allowedIds.teams.csv}}","TEAMS_PORT={{teamsConfig.webhookPort}}"]},{id:"teams-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.teams",value:{enabled:true}}}],runtime:{openclaw:{channelName:"msteams",visibility:{configKeys:["msteams"],logPatterns:["msteams","teams"]},nodePreloads:[{module:"msteams-message-hints",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Microsoft Teams message hint patch (native mentions)",installedMessage:"[channels] Microsoft Teams message hint patch installed (NODE_OPTIONS updated)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/msteams@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz"},required:true},{id:"hermesTeamsAppsPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"microsoft-teams-apps==2.0.13.4",required:true},{id:"hermesAiohttpPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"aiohttp==3.14.3",required:true}],hooks:[{id:"teams-host-forward-port-conflict",phase:"pre-enable",handler:"teams.hostForwardPortConflict",inputs:["webhookPort"],onFailure:"abort"},{id:"teams-host-forward-port-status",phase:"status",handler:"teams.hostForwardPortStatus",outputs:[{id:"hostForwardPortOverlaps",kind:"status"}]},{id:"teams-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"clientSecret",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"teams-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"appId",kind:"config",required:true},{id:"tenantId",kind:"config",required:true},{id:"allowedUsers",kind:"config"},{id:"webhookPort",kind:"config"},{id:"requireMention",kind:"config"}]}]};var telegramManifest={schemaVersion:1,id:"telegram",displayName:"Telegram",description:"Telegram bot messaging",diagnosticsProbe:"log-tail",enrollmentNotes:["For Telegram group chats, disable privacy mode in @BotFather (/setprivacy -> your bot -> Disable).","After changing privacy mode, remove and re-add the bot to each group before testing @mentions."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"TELEGRAM_BOT_TOKEN",prompt:{label:"Telegram Bot Token",help:"Create a bot via @BotFather on Telegram, then copy the token."}},{id:"allowedIds",kind:"config",required:false,envKey:"TELEGRAM_ALLOWED_IDS",statePath:"allowedIds.telegram",prompt:{label:"Telegram User ID (for DM access)",help:"Send /start to @userinfobot on Telegram to get your numeric user ID.",emptyValueMessage:"bot will require manual pairing"}},{id:"requireMention",kind:"config",required:false,envKey:"TELEGRAM_REQUIRE_MENTION",statePath:"telegramConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Telegram group mention mode",help:"Controls Telegram group-chat behavior only \u2014 reply only when @mentioned vs. to all group messages. Direct messages are unaffected by this setting and remain subject to pairing and TELEGRAM_ALLOWED_IDS."}},{id:"groupPolicy",kind:"config",required:false,envKey:"TELEGRAM_GROUP_POLICY",statePath:"telegramConfig.groupPolicy",validValues:["open","allowlist","disabled"],defaultValue:"open",prompt:{label:"Telegram group policy",help:"Controls OpenClaw Telegram group access. Hermes does not expose an equivalent disable-groups policy."}}],credentials:[{id:"telegramBotToken",sourceInput:"botToken",providerName:"{sandboxName}-telegram-bridge",providerEnvKey:"TELEGRAM_BOT_TOKEN",placeholder:"openshell:resolve:env:TELEGRAM_BOT_TOKEN"}],policyPresets:[{name:"telegram",policyKeys:["telegram_bot"],agentPolicyKeys:{hermes:["telegram"]}}],render:[{id:"telegram-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.telegram",value:{enabled:true,accounts:{default:{botToken:"{{credential.telegramBotToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},proxy:"{{proxyUrl}}",groupPolicy:"{{telegramConfig.groupPolicy}}",dmPolicy:"{{allowedIds.telegram.dmPolicy}}",allowFrom:"{{allowedIds.telegram.values}}"}}}}},{id:"telegram-openclaw-groups",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{telegramConfig.openclawGroups}}",fragment:{path:"channels.telegram.groups",value:"{{telegramConfig.openclawGroups}}"}},{id:"telegram-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.telegram",value:{enabled:true}}},{id:"telegram-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TELEGRAM_BOT_TOKEN={{credential.telegramBotToken.placeholder}}","TELEGRAM_ALLOWED_USERS={{allowedIds.telegram.csv}}"]},{id:"telegram-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"telegram",value:{require_mention:"{{telegramConfig.requireMention}}"}}},{id:"telegram-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.telegram",value:{enabled:true}}}],runtime:{openclaw:{channelName:"telegram",visibility:{configKeys:["telegram"],logPatterns:["telegram"]},nodePreloads:[{module:"telegram-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Telegram diagnostics (provider readiness + inference errors)",installedMessage:"[channels] Telegram diagnostics installed (NODE_OPTIONS updated)"}]}},hooks:[{id:"telegram-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"telegram-allowlist-aliases",phase:"enroll",handler:"telegram.allowlistAliases",outputs:[{id:"allowedIds",kind:"config"}]},{id:"telegram-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"requireMention",kind:"config"},{id:"allowedIds",kind:"config"}]},{id:"telegram-openclaw-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["openclaw"],outputs:[{id:"groupPolicy",kind:"config"}]},{id:"telegram-get-me-reachability",phase:"reachability-check",handler:"telegram.getMeReachability",inputs:["botToken"],onFailure:"skip-channel"},{id:"telegram-openclaw-bridge-health",phase:"health-check",handler:"telegram.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"telegram-gateway-conflict-status",phase:"status",handler:"telegram.gatewayConflictStatus",outputs:[{id:"bridgeHealth",kind:"status"}]},{id:"telegram-status-health",phase:"status",handler:"telegram.statusHealth",agents:["openclaw"],outputs:[{id:"channelHealth",kind:"status"}]}]};var WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT={channelId:"wechat",planHookId:"wechat-seed-openclaw-account",handlerId:"wechat.seedOpenClawAccount",outputId:"openclawWeixinAccountFile",kind:"build-file",required:true,mode:"0600"};var WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.handlerId;var WECHAT_SEED_OPENCLAW_ACCOUNT_PLAN_HOOK_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.planHookId;var WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.outputId;var WECHAT_TOKEN_PLACEHOLDER="openshell:resolve:env:WECHAT_BOT_TOKEN";function authorizeWechatAccountFilePlaceholders(value){if(!isPlainDataObject(value)||!isWechatAccountFilePath(ownDataPropertyValue(value,"path"))||ownDataPropertyValue(value,"mode")!==WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.mode||!isPlainDataObject(ownDataPropertyValue(value,"content"))){return[]}return[{path:["content","token"],value:WECHAT_TOKEN_PLACEHOLDER}]}function isWechatAccountFilePath(value){if(typeof value!=="string")return false;const prefix="openclaw-weixin/accounts/";const suffix=".json";if(!value.startsWith(prefix)||!value.endsWith(suffix))return false;const accountId=value.slice(prefix.length,-suffix.length);return accountId===accountId.trim()&&isSafeWechatAccountId(accountId)}function isSafeWechatAccountId(accountId){return accountId.length>0&&accountId!=="."&&accountId!==".."&&!/[\\/\0-\x1F\x7F]/.test(accountId)&&!accountId.includes("..")}function isPlainDataObject(value){return value!==null&&typeof value==="object"&&Object.getPrototypeOf(value)===Object.prototype}function ownDataPropertyValue(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}var wechatManifest={schemaVersion:1,id:"wechat",displayName:"WeChat",description:"WeChat (personal) bot messaging",enrollmentHelp:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only.",supportedAgents:["openclaw","hermes"],auth:{mode:"host-qr"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"WECHAT_BOT_TOKEN",prompt:{label:"WeChat Bot Token",help:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only."}},{id:"accountId",kind:"config",required:true,envKey:"WECHAT_ACCOUNT_ID",statePath:"wechatConfig.accountId"},{id:"baseUrl",kind:"config",required:false,envKey:"WECHAT_BASE_URL",statePath:"wechatConfig.baseUrl"},{id:"userId",kind:"config",required:false,envKey:"WECHAT_USER_ID",statePath:"wechatConfig.userId"},{id:"allowedIds",kind:"config",required:false,envKey:"WECHAT_ALLOWED_IDS",statePath:"allowedIds.wechat",prompt:{label:"WeChat User ID(s) (DM allowlist)",help:"Optional: restrict who can DM the bot. The WeChat user id of the operator who scanned is added automatically; supply additional ids as a comma-separated list.",emptyValueMessage:"bot will require manual pairing"}}],credentials:[{id:"wechatBotToken",sourceInput:"botToken",providerName:"{sandboxName}-wechat-bridge",providerEnvKey:"WECHAT_BOT_TOKEN",placeholder:"openshell:resolve:env:WECHAT_BOT_TOKEN"}],policyPresets:[{name:"wechat",policyKeys:["wechat_bridge"]}],render:[{id:"wechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.openclaw-weixin",value:{enabled:true}}},{id:"wechat-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WEIXIN_TOKEN={{credential.wechatBotToken.placeholder}}","WEIXIN_ACCOUNT_ID={{wechatConfig.accountId}}","WEIXIN_BASE_URL={{wechatConfig.baseUrl}}","WEIXIN_ALLOWED_USERS={{allowedIds.wechat.csv}}"]},{id:"wechat-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.weixin",value:{enabled:true}}}],runtime:{openclaw:{channelName:"openclaw-weixin",visibility:{configKeys:["openclaw-weixin"],logPatterns:["wechat","openclaw-weixin"]},nodePreloads:[{module:"wechat-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing WeChat diagnostics (provider readiness + inference errors)",installedMessage:"[channels] WeChat diagnostics installed (NODE_OPTIONS updated)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@tencent-weixin/openclaw-weixin@2.4.3",pin:true,integrity:"sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==",tarballUrl:"https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz",runtimeLock:{cachePath:"/usr/local/share/nemoclaw/wechat-npm-cache",installCacheEnvKey:"NEMOCLAW_WECHAT_NPM_INSTALL_CACHE",lockFile:"/usr/local/lib/nemoclaw/wechat-runtime/package-lock.json",projectsRoot:"/sandbox/.openclaw/npm/projects",verifierPath:"/usr/local/lib/nemoclaw/verify-wechat-runtime-lock.mts",offline:true,legacyPeerDeps:true},required:true}],hooks:[{id:"wechat-host-qr",phase:"enroll",handler:"wechat.ilinkLogin",inputs:["allowedIds"],outputs:[{id:"botToken",kind:"secret",required:true},{id:"accountId",kind:"config",required:true},{id:"baseUrl",kind:"config"},{id:"userId",kind:"config"},{id:"allowedIds",kind:"config"}],onFailure:"skip-channel"},{id:"wechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedIds",kind:"config"}]},{id:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.planHookId,phase:"post-agent-install",handler:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.handlerId,agents:["openclaw"],inputs:["wechatConfig.accountId","wechatConfig.baseUrl","wechatConfig.userId","credential.wechatBotToken.placeholder"],outputs:[{id:"openclawWeixinAccountsIndex",kind:"build-file",required:true},{id:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.outputId,kind:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.kind,required:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.required},{id:"openclawConfigPatch",kind:"build-file",required:true}],onFailure:"abort"},{id:"wechat-health-check",phase:"health-check",handler:"wechat.healthCheck",inputs:["wechatConfig.accountId"],onFailure:"abort"}]};var whatsappManifest={schemaVersion:1,id:"whatsapp",displayName:"WhatsApp",description:"WhatsApp Web messaging (QR pairing)",enrollmentHelp:"WhatsApp Web pairs via QR code scanned with your phone \u2014 no host-side token. After the sandbox is running, run `openshell term` and then use `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes to display the QR.",enrollmentNotes:["After pairing, run `nemoclaw channels status --channel whatsapp`. OpenClaw reports inbound delivery evidence; Hermes reports gateway and dashboard session-path diagnostics."],supportedAgents:["openclaw","hermes"],auth:{mode:"in-sandbox-qr"},inputs:[{id:"mode",kind:"config",required:false,envKey:"WHATSAPP_MODE",statePath:"whatsappConfig.mode",validValues:["self-chat","bot"],defaultValue:"self-chat",prompt:{label:"WhatsApp reply mode",help:"self-chat replies only to messages the paired account sends to itself. bot replies to other senders and stops replying to that self-chat: an unknown sender receives a pairing code you approve with `hermes pairing approve whatsapp `, unless you set WHATSAPP_ALLOWED_IDS to a fixed sender list before this command.",emptyValueMessage:"the sandbox replies only in your own self-chat"}},{id:"allowedIds",kind:"config",required:false,envKey:"WHATSAPP_ALLOWED_IDS",statePath:"allowedIds.whatsapp"}],credentials:[],policyPresets:["whatsapp"],render:[{id:"whatsapp-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.whatsapp",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false}}}}}},{id:"whatsapp-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.whatsapp",value:{enabled:true}}},{id:"whatsapp-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WHATSAPP_ENABLED=true","WHATSAPP_MODE={{whatsappConfig.mode}}","WHATSAPP_DM_POLICY={{whatsappConfig.dmPolicy}}","WHATSAPP_ALLOWED_USERS={{allowedIds.whatsapp.csv}}"]},{id:"whatsapp-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.whatsapp",value:{enabled:true}}}],runtime:{openclaw:{channelName:"whatsapp",visibility:{configKeys:["whatsapp"],logPatterns:["whatsapp"]},nodePreloads:[{module:"whatsapp-qr-compact",injectInto:["connect"],optional:true,installMessage:"[channels] Installing WhatsApp compact-QR renderer (scan-friendly pairing)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/whatsapp@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz"},required:true}],hooks:[{id:"whatsapp-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["hermes"],outputs:[{id:"mode",kind:"config"}]},{id:"whatsapp-status-health",phase:"status",handler:"whatsapp.statusHealth",agents:["openclaw","hermes"],outputs:[{id:"channelHealth",kind:"status"}]}]};var BUILT_IN_CHANNEL_MANIFESTS=[telegramManifest,discordManifest,wechatManifest,slackManifest,whatsappManifest,teamsManifest,googlechatManifest];function createBuiltInChannelManifestRegistry(){return createChannelManifestRegistry(BUILT_IN_CHANNEL_MANIFESTS)}var EXACT_TEMPLATE_PATTERN=/^\{\{\s*([^}]+?)\s*\}\}$/;var TEMPLATE_REFERENCE_PATTERN=/\{\{\s*([^}]+?)\s*\}\}/g;function resolvedRenderTemplateReference(value){return{matched:true,value}}function resolveSandboxNameTemplate(value,sandboxName){return value.replaceAll("{sandboxName}",sandboxName)}function resolveRenderTemplatesInValue(value,context){if(typeof value==="string")return resolveRenderTemplatesInString(value,context);if(Array.isArray(value)){if(value.length===0)return value;const resolved=value.map(entry=>resolveRenderTemplatesInValue(entry,context)).filter(entry=>entry!==void 0);return resolved.length>0?resolved:void 0}if(value&&typeof value==="object"){const sourceEntries=Object.entries(value);if(sourceEntries.length===0)return value;const entries=sourceEntries.map(([key,entry])=>[key,resolveRenderTemplatesInValue(entry,context)]).filter(entry=>entry[1]!==void 0);return entries.length>0?Object.fromEntries(entries):void 0}return value}function isTruthyRenderTemplate(value,context){if(!value)return true;const resolved=resolveRenderTemplatesInString(value,context);if(resolved===void 0||resolved===null||resolved===false)return false;if(Array.isArray(resolved))return resolved.length>0;if(typeof resolved==="object")return Object.keys(resolved).length>0;if(typeof resolved==="string")return resolved.trim().length>0;return true}function resolveRenderTemplatesInString(value,context){const exact=value.match(EXACT_TEMPLATE_PATTERN);if(exact?.[1])return resolveTemplateReference(exact[1].trim(),context);let omitted=false;const resolved=value.replace(TEMPLATE_REFERENCE_PATTERN,(match,reference)=>{const replacement=resolveTemplateReference(reference.trim(),context);if(replacement===void 0||replacement===null){omitted=true;return""}if(Array.isArray(replacement))return replacement.map(String).join(",");if(typeof replacement==="object")return JSON.stringify(replacement);return String(replacement)});return omitted?void 0:resolved}function resolveTemplateReference(reference,context){const resolved=context.referenceResolver?.(reference,context);return resolved?.matched?resolved.value:"{{"+reference+"}}"}function allowedIds(context,channel){return parseList(stateValue(context,`allowedIds.${channel}`))}function stateValue(context,path5){const stateInput=context.inputs.find(input=>input.statePath===path5);if(stateInput?.value!==void 0)return stateInput.value;const inputId=path5.split(".").at(-1);return context.inputs.find(input=>input.inputId===inputId)?.value}function parseList(value){if(Array.isArray(value))return unique(value.map(String).map(cleanString).filter(Boolean));const text=cleanString(value);if(!text)return[];return unique(text.split(",").map(cleanString).filter(Boolean))}function parseBoolean(value){if(typeof value==="boolean")return value;const text=cleanString(value)?.toLowerCase();if(text==="1"||text==="true"||text==="yes"||text==="on")return true;if(text==="0"||text==="false"||text==="no"||text==="off")return false;return void 0}function nonEmptyString(value){return cleanString(value)||void 0}function cleanString(value){const text=String(value??"");if(/[\r\n]/.test(text)){throw new Error("Messaging template values must not contain line breaks.")}return text.trim()}function nonEmptyArray(values){return values.length>0?[...values]:void 0}function nonEmptyCsv(values){return values.length>0?values.join(","):void 0}function nonEmptyObject(value){return Object.keys(value).length>0?value:void 0}function unique(values){return[...new Set(values)]}var resolveDiscordTemplateReference=(reference,context)=>{if(reference==="discordProxyUrl")return resolvedRenderTemplateReference(void 0);switch(reference){case"discord.guilds":return resolvedRenderTemplateReference(nonEmptyObject(discordGuilds(context)));case"discord.hasGuilds":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0);case"discord.guildIds.csv":return resolvedRenderTemplateReference(nonEmptyCsv(Object.keys(discordGuilds(context))));case"discord.allowedUsers.values":return resolvedRenderTemplateReference(nonEmptyArray(discordAllowedUsers(context)));case"discord.allowedUsers.csv":return resolvedRenderTemplateReference(nonEmptyCsv(discordAllowedUsers(context)));case"discord.allowedUsers.dmPolicy":return resolvedRenderTemplateReference(discordAllowedUsers(context).length>0?"allowlist":void 0);case"discord.allowAllUsers":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0&&discordAllowedUsers(context).length===0?true:void 0);case"discord.requireMention":return resolvedRenderTemplateReference(discordRequireMention(context));default:return void 0}};function discordGuilds(context){const serverIds=parseList(stateValue(context,"discordGuilds.serverId"));if(serverIds.length===0)return{};const users=parseList(stateValue(context,"discordGuilds.userIds"));const requireMention=parseBoolean(stateValue(context,"discordGuilds.requireMention"))??true;return Object.fromEntries(serverIds.map(serverId=>[serverId,{requireMention,...users.length>0?{users}:{}}]))}function discordAllowedUsers(context){const users=new Set(allowedIds(context,"discord"));for(const guild of Object.values(discordGuilds(context))){for(const user of guild.users??[])users.add(String(user))}return[...users]}function discordRequireMention(context){for(const guild of Object.values(discordGuilds(context))){if(typeof guild.requireMention==="boolean")return guild.requireMention}return true}var DEFAULT_AUDIENCE_TYPE="app-url";var APP_PRINCIPAL_DISCOVERY_SENTINEL="000000000000000000000";var resolveGooglechatTemplateReference=(reference,context)=>{switch(reference){case"googlechatConfig.audienceType":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audienceType"))??DEFAULT_AUDIENCE_TYPE);case"googlechatConfig.audience":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audience")));case"googlechatConfig.appPrincipal":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.appPrincipal"))??APP_PRINCIPAL_DISCOVERY_SENTINEL);case"googlechatConfig.projectId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.projectId")));case"googlechatConfig.subscriptionName":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.subscriptionName")));default:break}const allowReference=reference.match(/^allowedIds[.]googlechat[.](values|dmPolicy|csv)$/);if(!allowReference?.[1])return void 0;const ids=allowedIds(context,"googlechat");switch(allowReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);case"csv":return resolvedRenderTemplateReference(ids.length>0?ids.join(","):void 0);default:return void 0}};var resolveSlackTemplateReference=(reference,context)=>{if(reference==="slackConfig.allowedChannels.csv"){return resolvedRenderTemplateReference(nonEmptyCsv(slackAllowedChannels(context)))}const allowedIdsReference=reference.match(/^allowedIds[.]slack[.](values|csv|dmPolicy|groupPolicy|channels)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"slack");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);case"groupPolicy":return resolvedRenderTemplateReference(ids.length>0||slackAllowedChannels(context).length>0?"allowlist":void 0);case"channels":return resolvedRenderTemplateReference(slackChannelConfig(context,ids));default:return void 0}};function slackChannelConfig(context,users){const allowedChannels=slackAllowedChannels(context);const entry={enabled:true,requireMention:true,...users.length>0?{users:[...users]}:{}};if(allowedChannels.length>0){return Object.fromEntries(allowedChannels.map(channelId=>[channelId,{...entry}]))}return users.length>0?{"*":entry}:void 0}function slackAllowedChannels(context){return parseList(stateValue(context,"slackConfig.allowedChannels"))}var DEFAULT_TEAMS_WEBHOOK_PORT=3978;var resolveTeamsTemplateReference=(reference,context)=>{switch(reference){case"teamsConfig.appId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.appId")));case"teamsConfig.tenantId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.tenantId")));case"teamsConfig.webhookPort":return resolvedRenderTemplateReference(teamsWebhookPort(context));case"teamsConfig.requireMention":return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"teamsConfig.requireMention")));default:break}const allowedIdsReference=reference.match(/^allowedIds[.]teams[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"teams");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function teamsWebhookPort(context){const raw=nonEmptyString(stateValue(context,"teamsConfig.webhookPort"));if(!raw)return DEFAULT_TEAMS_WEBHOOK_PORT;const port=Number(raw);if(!Number.isInteger(port)||port<1||port>65535){throw new Error("Microsoft Teams webhook port must be an integer TCP port between 1 and 65535.")}return port}var DEFAULT_PROXY_HOST="10.200.0.1";var DEFAULT_PROXY_PORT="3128";var DEFAULT_TELEGRAM_GROUP_POLICY="open";var TELEGRAM_GROUP_POLICIES=new Set(["open","allowlist","disabled"]);var resolveTelegramTemplateReference=(reference,context)=>{if(reference==="proxyUrl")return resolvedRenderTemplateReference(proxyUrl(context.env));if(reference==="telegramConfig.groupPolicy"){return resolvedRenderTemplateReference(telegramGroupPolicy(context))}if(reference==="telegramConfig.openclawGroups"){return resolvedRenderTemplateReference(telegramOpenClawGroups(context))}if(reference==="telegramConfig.requireMention"){return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"telegramConfig.requireMention")))}const allowedIdsReference=reference.match(/^allowedIds[.]telegram[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"telegram");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function proxyUrl(env){const host=nonEmptyString(env?.NEMOCLAW_PROXY_HOST)??DEFAULT_PROXY_HOST;const port=nonEmptyString(env?.NEMOCLAW_PROXY_PORT)??DEFAULT_PROXY_PORT;return`http://${host}:${port}`}function telegramGroupPolicy(context){const value=nonEmptyString(stateValue(context,"telegramConfig.groupPolicy"));return value&&TELEGRAM_GROUP_POLICIES.has(value)?value:DEFAULT_TELEGRAM_GROUP_POLICY}function telegramOpenClawGroups(context){if(telegramGroupPolicy(context)!=="open")return void 0;const requireMention=parseBoolean(stateValue(context,"telegramConfig.requireMention"));return requireMention===true?{"*":{requireMention:true}}:void 0}var WECHAT_ILINK_HOSTS=new Set(["ilinkai.weixin.qq.com","ilinkai.wechat.com"]);var WECHAT_ILINK_IDC_HOST_PATTERN=/^idc-[0-9]+[.]weixin[.]qq[.]com$/;function normalizeWechatIlinkBaseUrl(value){const raw=String(value??"");if(/[\r\n]/.test(raw)){throw new Error("WeChat baseUrl must not contain line breaks.")}const text=raw.trim();if(!text)return void 0;let url;try{url=new URL(text)}catch{throw new Error("WeChat baseUrl must be a valid URL.")}if(url.protocol!=="https:"){throw new Error("WeChat baseUrl must use HTTPS.")}if(url.username||url.password){throw new Error("WeChat baseUrl must not include credentials.")}if(!isWechatIlinkHost(url.hostname)){throw new Error("WeChat baseUrl must use an expected iLink host.")}if(url.pathname&&url.pathname!=="/"||url.search||url.hash){throw new Error("WeChat baseUrl must be an iLink origin URL.")}return url.origin}function isWechatIlinkHost(hostname){const normalized=hostname.toLowerCase();return WECHAT_ILINK_HOSTS.has(normalized)||WECHAT_ILINK_IDC_HOST_PATTERN.test(normalized)}var resolveWechatTemplateReference=(reference,context)=>{const wechatConfig=reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/);if(wechatConfig?.[1]){if(wechatConfig[1]==="baseUrl"){return resolvedRenderTemplateReference(normalizeWechatIlinkBaseUrl(stateValue(context,"wechatConfig.baseUrl")))}return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"wechatConfig."+wechatConfig[1])))}const allowedIdsReference=reference.match(/^allowedIds[.]wechat[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=wechatAllowedIds(context);switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function wechatAllowedIds(context){const ids=allowedIds(context,"wechat");const userId=nonEmptyString(stateValue(context,"wechatConfig.userId"));return userId&&!ids.includes(userId)?[userId,...ids]:ids}var DEFAULT_WHATSAPP_MODE="self-chat";var BOT_WHATSAPP_MODE="bot";var WHATSAPP_MODES=new Set([DEFAULT_WHATSAPP_MODE,BOT_WHATSAPP_MODE]);var resolveWhatsappTemplateReference=(reference,context)=>{if(reference==="whatsappConfig.mode"){return resolvedRenderTemplateReference(whatsappMode(context))}if(reference==="whatsappConfig.dmPolicy"){return resolvedRenderTemplateReference(whatsappDmPolicy(context))}const allowedIdsReference=reference.match(/^allowedIds[.]whatsapp[.](values|csv)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"whatsapp");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));default:return void 0}};function whatsappMode(context){const value=nonEmptyString(stateValue(context,"whatsappConfig.mode"));return value&&WHATSAPP_MODES.has(value)?value:DEFAULT_WHATSAPP_MODE}function whatsappDmPolicy(context){if(whatsappMode(context)!==BOT_WHATSAPP_MODE)return void 0;return allowedIds(context,"whatsapp").length>0?"allowlist":"pairing"}var BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS=[resolveTelegramTemplateReference,resolveDiscordTemplateReference,resolveWechatTemplateReference,resolveSlackTemplateReference,resolveWhatsappTemplateReference,resolveTeamsTemplateReference,resolveGooglechatTemplateReference];function createBuiltInRenderTemplateResolver(){return(reference,context)=>{for(const resolver of BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS){const resolved=resolver(reference,context);if(resolved)return resolved}return void 0}}var import_node_crypto=__toESM(require("node:crypto"));function hashCredential(value){const normalized=String(value??"").trim();if(!normalized)return null;return import_node_crypto.default.createHash("sha256").update(normalized).digest("hex")}function planCredentialBindings(manifest,context,inputs,environment=process.env){return manifest.credentials.map(credential=>{const sourceInput=inputs.find(input=>input.inputId===credential.sourceInput);const credentialAvailable=sourceInput?.credentialAvailable===true||context.credentialAvailability?.[credential.id]===true||context.credentialAvailability?.[`${manifest.id}.${credential.id}`]===true;const envKey=sourceInput?.sourceEnv??credential.providerEnvKey;const credentialHash=credentialAvailable?hashCredential(environment[envKey])??void 0:void 0;return{channelId:manifest.id,credentialId:credential.id,sourceInput:credential.sourceInput,providerName:resolveSandboxNameTemplate(credential.providerName,context.sandboxName),providerEnvKey:credential.providerEnvKey,placeholder:credential.placeholder,credentialAvailable,...credentialHash!==void 0?{credentialHash}:{}}})}function planHostForward(manifest,inputs,active,referenceResolver,environment=process.env){if(!active||!manifest.hostForward)return void 0;const context={inputs,env:environment,referenceResolver};if(!isTruthyRenderTemplate(manifest.hostForward.when,context))return void 0;const portValue=resolveRenderTemplatesInValue(manifest.hostForward.port,context);const port=normalizeForwardPort(manifest.id,portValue);return{channelId:manifest.id,port,label:manifest.hostForward.label}}function normalizeForwardPort(channelId,value){const port=typeof value==="number"?value:Number(String(value??"").trim());if(!Number.isInteger(port)||port<1||port>65535){throw new Error(`Channel manifest '${channelId}' declares invalid host forward port '${String(value)}'.`)}return port}var OPENSHELL_ENV_PLACEHOLDER_PREFIX="openshell:resolve:env:";var OPENSHELL_ALIAS_PLACEHOLDER_RE=/^[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-(.+)$/;function normalizeProviderPlaceholderForEnvKey(value,envKey){if(value.startsWith(OPENSHELL_ENV_PLACEHOLDER_PREFIX)){return placeholderSuffixMatchesEnvKey(value.slice(OPENSHELL_ENV_PLACEHOLDER_PREFIX.length),envKey)?`${OPENSHELL_ENV_PLACEHOLDER_PREFIX}${envKey}`:null}const aliasMatch=value.match(OPENSHELL_ALIAS_PLACEHOLDER_RE);if(!aliasMatch||!placeholderSuffixMatchesEnvKey(aliasMatch[1],envKey)){return null}return value.replace(/-OPENSHELL-RESOLVE-ENV-.+$/,`-OPENSHELL-RESOLVE-ENV-${envKey}`)}function placeholderSuffixMatchesEnvKey(suffix,envKey){if(suffix===envKey)return true;const revisionMatch=suffix.match(/^v[0-9]+_(.+)$/);return revisionMatch?.[1]===envKey}function hasFullPersistedCredentialBindingShape(binding){return typeof binding.channelId==="string"&&typeof binding.credentialId==="string"&&typeof binding.sourceInput==="string"&&typeof binding.providerName==="string"&&typeof binding.providerEnvKey==="string"&&typeof binding.placeholder==="string"&&typeof binding.credentialAvailable==="boolean"}function normalizeFullPersistedCredentialBindings(bindings){return bindings.map(binding=>({channelId:binding.channelId,credentialId:binding.credentialId,sourceInput:binding.sourceInput,providerName:binding.providerName,providerEnvKey:binding.providerEnvKey,placeholder:normalizeProviderPlaceholderForEnvKey(binding.placeholder,binding.providerEnvKey)??binding.placeholder,credentialAvailable:binding.credentialAvailable===true,...typeof binding.credentialHash==="string"?{credentialHash:binding.credentialHash}:{}}))}function normalizePersistedAgentCredentialPlaceholders(render,credentialBindings){const credentialEnvKeys=new Set(credentialBindings.map(binding=>binding.providerEnvKey).filter(Boolean));if(credentialEnvKeys.size===0)return[...render];return render.map(entry=>{if(entry.kind!=="env-lines")return entry;return{...entry,lines:entry.lines.map(line=>normalizeCredentialEnvLine(line,credentialEnvKeys))}})}function normalizeCredentialEnvLine(line,credentialEnvKeys){const index=line.indexOf("=");if(index<=0)return line;const envKey=line.slice(0,index).trim();if(!credentialEnvKeys.has(envKey))return line;const value=line.slice(index+1);const normalized=normalizeProviderPlaceholderForEnvKey(value,envKey);return normalized?`${envKey}=${normalized}`:line}function normalizePersistedSandboxMessagingPlanShape(plan,environment=process.env){const manifestRegistry=createBuiltInChannelManifestRegistry();const disabledChannels=plan.disabledChannels.filter(channelId=>typeof channelId==="string");const disabledSet=new Set(disabledChannels);const channels=plan.channels.map(channel=>normalizePersistedChannel(channel,disabledSet,manifestRegistry.get(channel.channelId),environment));const credentialBindings=normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment);const normalizedPlan={...plan,channels,disabledChannels,credentialBindings,networkPolicy:plan.networkPolicy&&Array.isArray(plan.networkPolicy.entries)?plan.networkPolicy:{presets:[],entries:[]},agentRender:normalizePersistedAgentCredentialPlaceholders(Array.isArray(plan.agentRender)?[...plan.agentRender]:[],credentialBindings),buildSteps:Array.isArray(plan.buildSteps)?[...plan.buildSteps]:[],...plan.runtimeSetup!==void 0?{runtimeSetup:normalizeRuntimeSetup(plan.runtimeSetup)}:{},stateUpdates:Array.isArray(plan.stateUpdates)?[...plan.stateUpdates]:[],healthChecks:Array.isArray(plan.healthChecks)?[...plan.healthChecks]:[]};return normalizedPlan}function normalizePersistedChannel(channel,disabledSet,manifest,environment){const disabled=channel.disabled??disabledSet.has(channel.channelId);const configured=channel.configured??true;const hasFullShape=hasFullChannelShape(channel);const inputs=hasFullShape?normalizeFullInputs(channel.channelId,channel.inputs??[]):normalizePersistedInputs(channel,manifest);const active=channel.active??(configured&&!disabled&&requiredInputsAvailable(manifest,inputs));const hostForward=manifest?planHostForward(manifest,inputs,active&&!disabled,createBuiltInRenderTemplateResolver(),environment):void 0;return{channelId:channel.channelId,displayName:channel.displayName??manifest?.displayName??channel.channelId,authMode:channel.authMode??manifest?.auth.mode??"none",active,selected:channel.selected??configured,configured,disabled,inputs,...hostForward?{hostForward}:{},hooks:Array.isArray(channel.hooks)?[...channel.hooks]:[]}}function normalizePersistedInputs(channel,manifest){const persistedById=new Map((channel.inputs??[]).filter(input=>typeof input.inputId==="string").map(input=>[input.inputId,input]));const fromManifest=(manifest?.inputs??[]).map(input=>inputReferenceFromManifest(channel.channelId,input,persistedById.get(input.id)));const manifestInputIds=new Set((manifest?.inputs??[]).map(input=>input.id));const unknownInputs=[...persistedById.values()].flatMap(input=>{if(!input.inputId||manifestInputIds.has(input.inputId))return[];return[normalizeUnknownInput(channel.channelId,input)]});return[...fromManifest,...unknownInputs]}function normalizeFullInputs(channelId,inputs){return inputs.filter(input=>typeof input.inputId==="string").map(input=>({channelId:typeof input.channelId==="string"?input.channelId:channelId,inputId:input.inputId,kind:input.kind==="secret"||input.kind==="config"?input.kind:"config",required:typeof input.required==="boolean"?input.required:false,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}))}function inputReferenceFromManifest(channelId,input,persisted){return{channelId,inputId:input.id,kind:input.kind,required:input.required,...input.envKey?{sourceEnv:input.envKey}:{},...input.kind==="config"&&input.statePath?{statePath:input.statePath}:{},...persisted?.credentialAvailable!==void 0?{credentialAvailable:persisted.credentialAvailable}:{},...persisted?.value!==void 0?{value:persisted.value}:{}}}function normalizeUnknownInput(channelId,input){const kind=input.kind==="secret"||input.kind==="config"?input.kind:"config";return{channelId,inputId:input.inputId,kind,required:input.required===true,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}}function requiredInputsAvailable(manifest,inputs){if(!manifest)return true;return manifest.inputs.every(manifestInput=>{if(!manifestInput.required)return true;const input=inputs.find(entry=>entry.inputId===manifestInput.id);if(!input)return false;if(input.kind==="secret")return input.credentialAvailable===true;if(input.value===void 0)return false;return typeof input.value==="string"?input.value.trim().length>0:true})}function normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment){const persisted=plan.credentialBindings??[];if(Array.isArray(plan.credentialBindings)&&plan.channels.every(hasFullChannelShape)&&persisted.every(hasFullPersistedCredentialBindingShape)){return normalizeFullPersistedCredentialBindings(persisted)}const manifests=channels.flatMap(channel=>{const manifest=manifestRegistry.get(channel.channelId);return manifest?[manifest]:[]});const planForBindings={...plan,channels,credentialBindings:[],networkPolicy:{presets:[],entries:[]},agentRender:[],buildSteps:[],runtimeSetup:{nodePreloads:[],envAliases:[],secretScans:[]},stateUpdates:[],healthChecks:[]};const generated=credentialBindingsFromManifests(planForBindings,manifests,new Map(channels.map(channel=>[channel.channelId,channel.inputs])),environment);return generated.map(binding=>overlayPersistedCredentialBinding(binding,persisted))}function credentialBindingsFromManifests(plan,manifests,inputRegistry,environment){const context=compilerContext(plan);return manifests.flatMap(manifest=>planCredentialBindings(manifest,context,inputRegistry.get(manifest.id)??[],environment).map(binding=>overlayPersistedCredentialBinding(binding,plan.credentialBindings)))}function overlayPersistedCredentialBinding(binding,persisted){const match=persisted.find(candidate=>credentialBindingMatches(binding,candidate));if(!match)return binding;return{...binding,credentialAvailable:typeof match.credentialAvailable==="boolean"?match.credentialAvailable:binding.credentialAvailable,...typeof match.credentialHash==="string"&&match.credentialHash.length>0?{credentialHash:match.credentialHash}:binding.credentialHash?{credentialHash:binding.credentialHash}:{}}}function credentialBindingMatches(binding,candidate){if(candidate.channelId&&candidate.channelId!==binding.channelId)return false;if(candidate.providerEnvKey&&candidate.providerEnvKey===binding.providerEnvKey)return true;if(candidate.credentialId&&candidate.credentialId===binding.credentialId)return true;if(candidate.sourceInput&&candidate.sourceInput===binding.sourceInput)return true;return false}function hasFullChannelShape(channel){return typeof channel.displayName==="string"&&typeof channel.authMode==="string"&&typeof channel.active==="boolean"&&typeof channel.selected==="boolean"&&typeof channel.configured==="boolean"&&typeof channel.disabled==="boolean"&&Array.isArray(channel.inputs)}function normalizeRuntimeSetup(setup){return{nodePreloads:Array.isArray(setup?.nodePreloads)?[...setup.nodePreloads]:[],envAliases:Array.isArray(setup?.envAliases)?[...setup.envAliases]:[],secretScans:Array.isArray(setup?.secretScans)?[...setup.secretScans]:[]}}function compilerContext(plan){return{sandboxName:plan.sandboxName,agent:plan.agent,workflow:plan.workflow,isInteractive:false,configuredChannels:plan.channels.map(channel=>channel.channelId),disabledChannels:plan.disabledChannels,credentialAvailability:credentialAvailabilityFromPlan(plan)}}function credentialAvailabilityFromPlan(plan){const availability={};for(const channel of plan.channels){for(const input of channel.inputs){if(input.kind!=="secret"||input.credentialAvailable!==true)continue;availability[`${channel.channelId}.${input.inputId}`]=true;if(input.sourceEnv)availability[input.sourceEnv]=true}}for(const credential of plan.credentialBindings){if(!credential.credentialAvailable)continue;availability[credential.credentialId]=true;availability[`${credential.channelId}.${credential.credentialId}`]=true;availability[`${credential.channelId}.${credential.sourceInput}`]=true;availability[credential.providerEnvKey]=true}return availability}function normalizeMessagingChannelId(channelId){return channelId.trim().toLowerCase()}function enabledPlanChannels(plan){const disabled=new Set((plan.disabledChannels??[]).map(normalizeMessagingChannelId).filter(Boolean));return plan.channels.filter(channel=>{const channelId=normalizeMessagingChannelId(channel.channelId);return channelId.length>0&&channel.active&&!channel.disabled&&!disabled.has(channelId)})}function selectActiveMessagingChannelIds(plan){const seen=new Set;const channels=[];for(const item of enabledPlanChannels(plan)){const channel=normalizeMessagingChannelId(item.channelId);if(!channel||seen.has(channel))continue;seen.add(channel);channels.push(channel)}return channels}function selectEnabledMessagingAgentRender(plan){const active=new Set(selectActiveMessagingChannelIds(plan));return plan.agentRender.filter(render=>render.agent===plan.agent&&active.has(normalizeMessagingChannelId(render.channelId)))}function selectEnabledPostAgentInstallBuildFiles(plan){const active=new Set(selectActiveMessagingChannelIds(plan));const channels=enabledPlanChannels(plan);return plan.buildSteps.filter(step=>{const channelId=normalizeMessagingChannelId(step.channelId);if(!active.has(channelId)||step.kind!=="build-file")return false;if(!step.hookId)return true;const matchingChannels=channels.filter(channel=>normalizeMessagingChannelId(channel.channelId)===channelId);if(matchingChannels.length!==1)return false;const matchedHook=matchingChannels[0]?.hooks?.find(hook=>hook.id===step.hookId);return matchedHook!==void 0&&matchedHook.phase==="post-agent-install"})}function parseSandboxMessagingPlan(value,options={}){if(!isObjectRecord(value)||value.schemaVersion!==1||typeof value.sandboxName!=="string"||typeof value.agent!=="string"||typeof value.workflow!=="string"||!Array.isArray(value.channels)||!Array.isArray(value.disabledChannels)||!isOptionalObjectArray(value,"credentialBindings")||Object.hasOwn(value,"networkPolicy")&&!isObjectRecord(value.networkPolicy)||!isOptionalObjectArray(value,"agentRender")||!isOptionalObjectArray(value,"buildSteps")||!isRuntimeSetup(value.runtimeSetup)||!isOptionalObjectArray(value,"stateUpdates")||!isOptionalObjectArray(value,"healthChecks")){return null}if(options.sandboxName&&value.sandboxName!==options.sandboxName)return null;if(options.agent&&value.agent!==options.agent)return null;const supported=Array.isArray(options.supportedChannelIds)?new Set(options.supportedChannelIds):null;const normalizedChannelIds=new Set;for(const channel of value.channels){if(!isObjectRecord(channel)||typeof channel.channelId!=="string")return null;const normalizedChannelId=normalizeMessagingChannelId(channel.channelId);if(!normalizedChannelId||normalizedChannelId!==channel.channelId||normalizedChannelIds.has(normalizedChannelId)){return null}if(Object.hasOwn(channel,"configured")&&typeof channel.configured!=="boolean"){return null}if(Object.hasOwn(channel,"active")&&typeof channel.active!=="boolean")return null;if(Object.hasOwn(channel,"disabled")&&typeof channel.disabled!=="boolean")return null;if(Object.hasOwn(channel,"inputs")&&!Array.isArray(channel.inputs))return null;if(Object.hasOwn(channel,"hostForward")&&!isHostForward(channel.hostForward))return null;if(Object.hasOwn(channel,"hooks")&&!Array.isArray(channel.hooks))return null;if(Array.isArray(channel.inputs)&&channel.inputs.some(input=>!isObjectRecord(input)||typeof input.inputId!=="string"||Object.hasOwn(input,"channelId")&&input.channelId!==normalizedChannelId)){return null}if(Array.isArray(channel.hooks)&&channel.hooks.some(hook=>!isObjectRecord(hook)||Object.hasOwn(hook,"channelId")&&hook.channelId!==normalizedChannelId)){return null}if(Object.hasOwn(channel,"hostForward")&&isObjectRecord(channel.hostForward)&&channel.hostForward.channelId!==normalizedChannelId){return null}if(supported&&!supported.has(channel.channelId))return null;normalizedChannelIds.add(normalizedChannelId)}if(!value.disabledChannels.every(isCanonicalMessagingChannelId))return null;const disabledChannelIds=new Set(value.disabledChannels);if(disabledChannelIds.size!==value.disabledChannels.length||[...disabledChannelIds].some(channelId=>!normalizedChannelIds.has(channelId))||value.channels.some(channel=>isObjectRecord(channel)&&channel.disabled===true!==disabledChannelIds.has(String(channel.channelId)))){return null}if(!hasCanonicalChannelReferences(value.credentialBindings)||!hasMatchingAgentRenderEntries(value.agentRender,value.agent)||!hasCanonicalChannelReferences(value.agentRender)||!hasCanonicalChannelReferences(value.buildSteps)||!hasCanonicalChannelReferences(value.stateUpdates)||!hasCanonicalChannelReferences(value.healthChecks)||!hasCanonicalNetworkPolicyReferences(value.networkPolicy)||!hasCanonicalRuntimeSetupReferences(value.runtimeSetup)){return null}return cloneSandboxMessagingPlan(normalizePersistedSandboxMessagingPlanShape(value,options.environment))}function hasMatchingAgentRenderEntries(value,agent){return!Array.isArray(value)||value.every(render=>isObjectRecord(render)&&render.agent===agent)}function cloneSandboxMessagingPlan(plan){return JSON.parse(JSON.stringify(plan))}function isOptionalObjectArray(value,key){if(!Object.hasOwn(value,key))return true;const entries=value[key];return Array.isArray(entries)&&entries.every(isObjectRecord)}function isHostForward(value){return isObjectRecord(value)&&typeof value.channelId==="string"&&typeof value.port==="number"&&Number.isInteger(value.port)&&value.port>=1&&value.port<=65535&&typeof value.label==="string"}function isRuntimeSetup(value){if(value===void 0)return true;return isObjectRecord(value)&&Array.isArray(value.nodePreloads)&&Array.isArray(value.envAliases)&&Array.isArray(value.secretScans)&&value.nodePreloads.every(isObjectRecord)&&value.envAliases.every(isObjectRecord)&&value.secretScans.every(isObjectRecord)}function isCanonicalMessagingChannelId(value){return typeof value==="string"&&value.length>0&&normalizeMessagingChannelId(value)===value}function hasCanonicalChannelReferences(value){return value===void 0||Array.isArray(value)&&value.every(entry=>isObjectRecord(entry)&&isCanonicalMessagingChannelId(entry.channelId))}function hasCanonicalNetworkPolicyReferences(value){if(!isObjectRecord(value)||!Object.hasOwn(value,"entries"))return true;return hasCanonicalChannelReferences(value.entries)}function hasCanonicalRuntimeSetupReferences(value){if(value===void 0)return true;if(!isObjectRecord(value))return false;return["nodePreloads","envAliases","secretScans"].every(field=>hasCanonicalChannelReferences(value[field]))}var import_node_buffer=require("node:buffer");var import_node_crypto2=require("node:crypto");var import_node_util=require("node:util");function listMessagingCredentialEnvAssignments(options={}){return selectManifests(options).flatMap(manifest=>{const credentialsByTemplate=new Map(manifest.credentials.map(credential=>[`{{credential.${credential.id}.placeholder}}`,credential]));return manifest.render.flatMap(render=>{if(options.agent&&render.agent!==options.agent)return[];if(render.kind!=="env-lines")return[];return render.lines.flatMap(line=>{const separator=line.indexOf("=");if(separator<=0)return[];const credential=credentialsByTemplate.get(line.slice(separator+1));if(!credential)return[];return[{channelId:manifest.id,agent:render.agent,sourceEnvKey:credential.providerEnvKey,targetEnvKey:line.slice(0,separator),placeholder:credential.placeholder}]})})})}function selectManifests(options){const manifests=options.manifests??BUILT_IN_CHANNEL_MANIFESTS;const agent=options.agent;const selected=agent?manifests.filter(manifest=>manifest.supportedAgents.includes(agent)):manifests;return[...selected]}function authorizeMessagingManagedStartupPlaceholders(step){if(!isPlainDataObject2(step))return[];const contract=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT;if(ownDataPropertyValue2(step,"channelId")!==contract.channelId||ownDataPropertyValue2(step,"hookId")!==contract.planHookId||ownDataPropertyValue2(step,"handler")!==contract.handlerId||ownDataPropertyValue2(step,"outputId")!==contract.outputId||ownDataPropertyValue2(step,"kind")!==contract.kind||ownDataPropertyValue2(step,"required")!==contract.required){return[]}return authorizeWechatAccountFilePlaceholders(ownDataPropertyValue2(step,"value")).map(authorization=>({...authorization,path:["value",...authorization.path]}))}function isPlainDataObject2(value){return value!==null&&typeof value==="object"&&Object.getPrototypeOf(value)===Object.prototype}function ownDataPropertyValue2(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}var DCODE_UPSTREAM_PROVIDER_RE=/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;function isValidDcodeUpstreamProvider(value){return DCODE_UPSTREAM_PROVIDER_RE.test(value)}var MANAGED_STARTUP_PROFILE_SCHEMA_VERSION=1;var MANAGED_STARTUP_PROFILE_MAX_BYTES=64*1024;var MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES=Math.ceil(MANAGED_STARTUP_PROFILE_MAX_BYTES/3)*4;var MAX_IDENTIFIER_BYTES=256;var MAX_MODEL_BYTES=1024;var MAX_URL_BYTES=2048;var MAX_LIST_ITEMS=128;var MAX_JSON_NODES=4096;var MAX_JSON_DEPTH=32;var MAX_TUNING_INTEGER=1e9;var MIN_HERMES_CONTEXT_WINDOW=64e3;var SHA256_RE=/^[a-f0-9]{64}$/;var CONTROL_CHARACTER_RE=/[\u0000-\u001f\u007f-\u009f]/u;var BASE64URL_RE=/^[A-Za-z0-9_-]+$/;var RAW_CA_PEM_RE=/-----BEGIN (?:TRUSTED )?CERTIFICATE-----/iu;var RAW_CA_PEM_BASE64_RE=/^LS0tLS1CRUdJTi(?:BDRVJUSUZJQ0FURS0tLS0t|BUlVTVEVEIENFUlRJRklDQVRFLS0tLS0)/u;var RAW_CA_DER_BASE64_RE=/^MII[A-Za-z0-9+/=\r\n]{253,}$/u;var RAW_CA_DATA_URI_RE=/data:application\/(?:pkix-cert|x-x509-ca-cert);base64,MII[A-Za-z0-9+/=]{253,}/iu;var URL_CANDIDATE_RE=/[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'<>]+/gu;var UTF8_DECODER=new import_node_util.TextDecoder("utf-8",{fatal:true});var CREDENTIAL_SHAPED_NAME_PATTERN=/(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/iu;var CREDENTIAL_COMPOUND_NAME_PATTERN=/^(?:access|refresh|client|bearer|auth|api|private|signing|session|bot|app|resolved)(?:token|key|secret|password)$/iu;var CREDENTIAL_CAMEL_SUFFIX_PATTERN=/(?:apiKey|accessKey|secretKey|authToken|refreshToken|accessToken|clientSecret|privateKey|passcode|password|passwd|passphrase|bearerToken|botToken|appToken|sessionToken|signingKey|secretPublicKey|personalAccessToken|connectionString|webhookUrl)$/iu;var CREDENTIAL_CAMEL_BOUNDARY_PATTERN=/[a-z0-9](?:Token|Key|Secret|Password|Passphrase|Pat)$/u;var CREDENTIAL_ENV_NAME_PATTERN=/^(?:[A-Z0-9]+_)*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PASS|PASSPHRASE|CREDENTIAL)S?$/u;var CREDENTIAL_HEADER_NAME_PATTERN=/^(?:authorization|proxy-authorization|cookie|set-cookie|.+-(?:key|token|secret|password|passphrase|credential|auth)s?)$/iu;var PUBLIC_KEY_NAME_PATTERN=/^public[-_]?keys?$/iu;var PASS_CREDENTIAL_NAME_PATTERN=/(?:^|[-_])pass(?:wd)?$/iu;var NON_SECRET_KEY_METADATA_NAMES=new Set(["envKey","installCacheEnvKey","providerEnvKey","stateKey"]);var MESSAGING_CREDENTIAL_PLACEHOLDER_RE=/^(?:openshell:resolve:env:|[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-)(?:v[0-9]+_)?[A-Z][A-Z0-9_]*$/u;var MESSAGING_CREDENTIAL_ENV_ALIASES=new Set(listMessagingCredentialEnvAssignments().filter(({sourceEnvKey,targetEnvKey})=>sourceEnvKey!==targetEnvKey).map(({agent,sourceEnvKey,targetEnvKey})=>`${agent}\0${sourceEnvKey}\0${targetEnvKey}`));var JSON_ARRAY_INDEX_SEGMENT_RE=/^\[(?:0|[1-9][0-9]*)\]$/u;var SECRET_VALUE_PATTERNS=[/nvapi-[A-Za-z0-9_-]{10,}/u,/nvcf-[A-Za-z0-9_-]{10,}/u,/ghp_[A-Za-z0-9_-]{10,}/u,/github_pat_[A-Za-z0-9_]{30,}/u,/sk-(?:proj-|ant-)?[A-Za-z0-9_-]{10,}/u,/(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}/u,/A(?:K|S)IA[A-Z0-9]{16}/u,/hf_[A-Za-z0-9]{10,}/u,/glpat-[A-Za-z0-9_-]{10,}/u,/gsk_[A-Za-z0-9]{10,}/u,/pypi-[A-Za-z0-9_-]{10,}/u,/tvly-[A-Za-z0-9_-]{10,}/u,/lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/u,/\bbot\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/u,/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/u,/\bBearer\s+[A-Za-z0-9_.+/=-]{10,}/iu,/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----/u];var MANAGED_STARTUP_INFERENCE_APIS=["openai-completions","openai-responses","anthropic-messages"];var MANAGED_STARTUP_REASONING_EFFORTS=["default","low","medium","high"];var MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES=["disabled","thread-opt-in"];var MANAGED_STARTUP_HERMES_TOOL_GATEWAYS=["nous-web","nous-image","nous-audio","nous-browser","nous-code"];var MANAGED_STARTUP_AGENTS=["openclaw","hermes","langchain-deepagents-code","pi"];var MANAGED_STARTUP_MESSAGING_AGENTS=["openclaw","hermes"];function freezeAgentCapabilities(capabilities){return Object.freeze({...capabilities,inferenceApis:Object.freeze([...capabilities.inferenceApis]),dashboardModes:Object.freeze([...capabilities.dashboardModes]),inputModalities:Object.freeze([...capabilities.inputModalities]),webSearchProviders:Object.freeze([...capabilities.webSearchProviders]),toolGateways:Object.freeze([...capabilities.toolGateways]),tuningFields:Object.freeze([...capabilities.tuningFields])})}var PROFILE_CAPABILITIES={openclaw:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["loopback","remote"],inputModalities:["text","image"],webSearchProviders:["brave","tavily"],toolGateways:[],tuningFields:["contextWindow","maxTokens","reasoning","reasoningEffort"],supportsMessaging:true,supportsInferenceCompatibility:true,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:true,supportsAgentTimeout:true,supportsHeartbeat:true,supportsExtraAgents:true,supportsDeviceAuth:true,observability:"openclaw-otel",supportsMinimalBootstrap:true},hermes:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["disabled","loopback-forwarded"],inputModalities:[],webSearchProviders:["tavily"],toolGateways:[...MANAGED_STARTUP_HERMES_TOOL_GATEWAYS],tuningFields:["contextWindow"],supportsMessaging:true,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"none",supportsMinimalBootstrap:false},"langchain-deepagents-code":{inferenceApis:["openai-completions"],dashboardModes:["disabled"],inputModalities:[],webSearchProviders:[],toolGateways:[],tuningFields:["reasoningEffort"],supportsMessaging:false,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:true,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"dcode-marker",supportsMinimalBootstrap:false},pi:{inferenceApis:["openai-completions"],dashboardModes:["disabled"],inputModalities:[],webSearchProviders:[],toolGateways:[],tuningFields:["contextWindow","maxTokens","reasoning"],supportsMessaging:false,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"none",supportsMinimalBootstrap:false}};for(const agent of MANAGED_STARTUP_AGENTS){Object.defineProperty(PROFILE_CAPABILITIES,agent,{configurable:false,enumerable:true,value:freezeAgentCapabilities(PROFILE_CAPABILITIES[agent]),writable:false})}var MANAGED_STARTUP_PROFILE_CAPABILITIES=Object.freeze(PROFILE_CAPABILITIES);function affordance(input,profilePath,source="docker-arg",representation="value"){return{input,profilePath,source,representation}}var HOST_PROXY_AFFORDANCES=[affordance("HTTP_PROXY","proxy.hostHttpUrl","runtime-env"),affordance("http_proxy","proxy.hostHttpUrl","runtime-env","derived"),affordance("HTTPS_PROXY","proxy.hostHttpsUrl","runtime-env"),affordance("https_proxy","proxy.hostHttpsUrl","runtime-env","derived"),affordance("NO_PROXY","proxy.hostNoProxy","runtime-env"),affordance("no_proxy","proxy.hostNoProxy","runtime-env","derived")];var MANAGED_STARTUP_PROFILE_AFFORDANCE_INVENTORY={openclaw:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_PRIMARY_MODEL_REF","inference.primaryModelRef"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_INFERENCE_COMPAT_B64","inference.compatibility"),affordance("NEMOCLAW_INFERENCE_INPUTS","inference.inputModalities"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_MAX_TOKENS","tuning.maxTokens"),affordance("NEMOCLAW_REASONING","tuning.reasoning"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_AGENT_TIMEOUT","agentConfig.agentTimeoutSeconds"),affordance("NEMOCLAW_AGENT_HEARTBEAT_EVERY","agentConfig.heartbeatEvery"),affordance("NEMOCLAW_EXTRA_AGENTS_JSON_B64","agentConfig.extraAgents"),affordance("NEMOCLAW_DISABLE_DEVICE_AUTH","agentConfig.deviceAuth.disabled"),affordance("NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE","agentConfig.deviceAuth.optOutSource"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_OPENCLAW_OTEL","agentConfig.otel.enabled"),affordance("NEMOCLAW_OPENCLAW_OTEL_ENDPOINT","agentConfig.otel.endpointUrl"),affordance("NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME","agentConfig.otel.serviceName"),affordance("NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE","agentConfig.otel.sampleRate"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_BIND","dashboard.bindAddress"),affordance("NEMOCLAW_WSL_DASHBOARD_EXPOSURE","dashboard.wslExposure"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.port","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("NEMOCLAW_MINIMAL_BOOTSTRAP","agentConfig.minimalBootstrap","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],hermes:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER","tools.enabledGateways","docker-arg","derived"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64","tools.enabledGateways"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD","dashboard.mode","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT","dashboard.internalPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_TUI","dashboard.tuiEnabled","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost","runtime-env"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],"langchain-deepagents-code":[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_UPSTREAM_ENDPOINT_URL","inference.upstreamEndpointUrl"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_DCODE_AUTO_APPROVAL","agentConfig.autoApprovalMode"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_OBSERVABILITY","agentConfig.observabilityEnabled","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],pi:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_MAX_TOKENS","tuning.maxTokens"),affordance("NEMOCLAW_REASONING","tuning.reasoning"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES]};function deferredRuntimeInput(input,owner,reason,admission="managed-launch-forwarded"){return Object.freeze({input,owner,admission,reason})}var MANAGED_STARTUP_PROFILE_DEFERRED_RUNTIME_INPUTS=Object.freeze({openclaw:Object.freeze([deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_SHADOW_DIAGNOSTICS","application-environment","operator shadow-diagnostics tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_TOOLS_LIST_TIMEOUT_MS","application-environment","operator MCP discovery timeout tuning is applied by the application environment transaction"),deferredRuntimeInput("OPENCLAW_HOME","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_STATE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_WORKSPACE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),hermes:Object.freeze([deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),"langchain-deepagents-code":Object.freeze([deferredRuntimeInput("NEMOCLAW_SANDBOX_NAME","engine-identity","the lifecycle engine owns instance identity outside reusable startup intent"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),pi:Object.freeze([deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")])});function runtimeCleanupObligation(input,emittedFor,supportedFor,reason){return Object.freeze({input,emittedFor:Object.freeze([...emittedFor]),supportedFor:Object.freeze([...supportedFor]),owner:"application-environment",reason})}var MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS=Object.freeze([runtimeCleanupObligation("NEMOCLAW_DASHBOARD_BIND",["hermes"],["openclaw"],"generic managed-dashboard construction currently emits the OpenClaw-only bind control for Hermes"),runtimeCleanupObligation("NEMOCLAW_MINIMAL_BOOTSTRAP",["hermes","langchain-deepagents-code"],["openclaw"],"generic host-proxy construction currently emits the OpenClaw-only bootstrap control for other agents")]);var ManagedStartupProfileError=class extends Error{constructor(message){super(`Invalid managed startup profile: ${message}`);this.name="ManagedStartupProfileError"}};var PROFILE_KEYS=new Set(["schemaVersion","agent","agentConfig","inference","proxy","dashboard","tools","messaging","tuning","corporateCa"]);var INFERENCE_KEYS=new Set(["routeProvider","upstreamProvider","model","routedBaseUrl","upstreamEndpointUrl","api","primaryModelRef","compatibility","inputModalities"]);var PROXY_KEYS=new Set(["managedHost","managedPort","hostHttpUrl","hostHttpsUrl","hostNoProxy"]);var OPENCLAW_DASHBOARD_KEYS=new Set(["agent","mode","url","port","bindAddress","wslExposure"]);var HERMES_DASHBOARD_KEYS=new Set(["agent","mode","url","publicPort","internalPort","tuiEnabled"]);var DCODE_DASHBOARD_KEYS=new Set(["agent","mode"]);var TOOLS_KEYS=new Set(["disclosure","enabledGateways"]);var MESSAGING_KEYS=new Set(["plan"]);var TUNING_FIELD_ORDER=["contextWindow","maxTokens","reasoning","reasoningEffort"];var TUNING_KEYS=new Set(TUNING_FIELD_ORDER);var CORPORATE_CA_KEYS=new Set(["bundleSha256"]);var OPENCLAW_CONFIG_KEYS=new Set(["agent","webSearch","otel","agentTimeoutSeconds","heartbeatEvery","extraAgents","deviceAuth","minimalBootstrap"]);var HERMES_CONFIG_KEYS=new Set(["agent","webSearch"]);var DCODE_CONFIG_KEYS=new Set(["agent","autoApprovalMode","observabilityEnabled"]);var PI_CONFIG_KEYS=new Set(["agent"]);var PI_DASHBOARD_KEYS=new Set(["agent","mode"]);var WEB_SEARCH_KEYS=new Set(["enabled","provider"]);var OTEL_KEYS=new Set(["enabled","endpointUrl","serviceName","sampleRate"]);var DEVICE_AUTH_KEYS=new Set(["disabled","optOutSource"]);var EXTRA_AGENTS_KEYS=new Set(["agents","defaults","main"]);var MANAGED_STARTUP_AGENT_SET=new Set(MANAGED_STARTUP_AGENTS);var DCODE_AUTO_APPROVAL_MODE_SET=new Set(MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES);var REASONING_EFFORT_SET=new Set(MANAGED_STARTUP_REASONING_EFFORTS);var HERMES_INTERNAL_API_PORT=18642;var HERMES_API_PORT_RANGE_START=8642;var HERMES_API_PORT_RANGE_END=8652;function isHermesApiPort(port){return port>=HERMES_API_PORT_RANGE_START&&port<=HERMES_API_PORT_RANGE_END}function isHermesReservedApiPort(port){return port===HERMES_INTERNAL_API_PORT||isHermesApiPort(port)}var HERMES_RESERVED_API_PORT_LABEL=`${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END} or ${HERMES_INTERNAL_API_PORT}`;function isPlainObject(value){if(typeof value!=="object"||value===null||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function isCredentialShapedName(name){if(PUBLIC_KEY_NAME_PATTERN.test(name)||NON_SECRET_KEY_METADATA_NAMES.has(name))return false;return CREDENTIAL_SHAPED_NAME_PATTERN.test(name)||CREDENTIAL_COMPOUND_NAME_PATTERN.test(name)||CREDENTIAL_CAMEL_SUFFIX_PATTERN.test(name)||CREDENTIAL_CAMEL_BOUNDARY_PATTERN.test(name)||CREDENTIAL_ENV_NAME_PATTERN.test(name)||CREDENTIAL_HEADER_NAME_PATTERN.test(name)||PASS_CREDENTIAL_NAME_PATTERN.test(name)}function valueLooksLikeSecret(value){for(let index=0;index=5&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="agentRender"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")&&path5[4]==="value";const isAuthorizedBuildStepPlaceholder=allowedBuildStepPlaceholders.has(buildStepPlaceholderKey(path5,value));return isCredentialBindingPlaceholder||isAgentRenderValuePlaceholder||isAuthorizedBuildStepPlaceholder}function buildStepPlaceholderKey(path5,value){return JSON.stringify([path5,value])}function messagingCredentialPlaceholderEnvKey(value){if(!MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value))return null;const marker=value.startsWith("openshell:resolve:env:")?"openshell:resolve:env:":"-OPENSHELL-RESOLVE-ENV-";const key=value.slice(value.indexOf(marker)+marker.length);return key.replace(/^v[0-9]+_/u,"")}function containsMessagingCredentialPlaceholder(value){return value.includes("openshell:resolve:env:")||value.includes("-OPENSHELL-RESOLVE-ENV-")}function isMessagingCredentialPlaceholderAssignment(selectedAgent,path5,value){if(path5.length!==6||path5[0]!=="messaging"||path5[1]!=="plan"||path5[2]!=="agentRender"||!JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")||path5[4]!=="lines"||!JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[5]??"")){return false}const separator=value.indexOf("=");if(separator<=0||value.indexOf("=",separator+1)!==-1)return false;const envKey=value.slice(0,separator);const placeholder=value.slice(separator+1);const placeholderEnvKey=messagingCredentialPlaceholderEnvKey(placeholder);return CREDENTIAL_ENV_NAME_PATTERN.test(envKey)&&placeholderEnvKey!==null&&(envKey===placeholderEnvKey||typeof selectedAgent==="string"&&MESSAGING_CREDENTIAL_ENV_ALIASES.has(`${selectedAgent}\0${placeholderEnvKey}\0${envKey}`))}function isMessagingRuntimeEnvAliasPath(path5){return path5.length===5&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="runtimeSetup"&&path5[3]==="envAliases"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[4]??"")}function ownDataPropertyValue3(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}function isStockTeamsOpenClawWebhook(root,path5,value){if(path5.length!==6||path5[0]!=="messaging"||path5[1]!=="plan"||path5[2]!=="agentRender"||!JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")||path5[4]!=="value"||path5[5]!=="webhook"||!isPlainObject(root)||ownDataPropertyValue3(root,"agent")!=="openclaw"){return false}const messaging=ownDataPropertyValue3(root,"messaging");if(!isPlainObject(messaging))return false;const plan=ownDataPropertyValue3(messaging,"plan");if(!isPlainObject(plan)||ownDataPropertyValue3(plan,"agent")!=="openclaw")return false;const agentRender=ownDataPropertyValue3(plan,"agentRender");if(!Array.isArray(agentRender))return false;const entryIndex=path5[3].slice(1,-1);const entryDescriptor=Object.getOwnPropertyDescriptor(agentRender,entryIndex);const entry=entryDescriptor&&"value"in entryDescriptor?entryDescriptor.value:void 0;if(!isPlainObject(entry))return false;const renderValue=ownDataPropertyValue3(entry,"value");if(!isPlainObject(renderValue)||ownDataPropertyValue3(renderValue,"webhook")!==value){return false}if(ownDataPropertyValue3(entry,"channelId")!=="teams"||ownDataPropertyValue3(entry,"renderId")!=="teams-openclaw-channel"||ownDataPropertyValue3(entry,"hookId")!=="teams-openclaw-channel"||ownDataPropertyValue3(entry,"handler")!=="common.staticOutputs"||ownDataPropertyValue3(entry,"kind")!=="json-fragment"||ownDataPropertyValue3(entry,"agent")!=="openclaw"||ownDataPropertyValue3(entry,"target")!=="openclaw.json"||ownDataPropertyValue3(entry,"path")!=="channels.msteams"||!isPlainObject(value)){return false}const keys=Object.getOwnPropertyNames(value);if(keys.length!==2||!keys.includes("port")||!keys.includes("path"))return false;const port=ownDataPropertyValue3(value,"port");return typeof port==="number"&&Number.isInteger(port)&&port>=1&&port<=65535&&ownDataPropertyValue3(value,"path")==="/api/messages"}function isCanonicalMessagingRuntimeEnvAlias(path5,value){if(!isMessagingRuntimeEnvAliasPath(path5))return false;const envKey=ownDataPropertyValue3(value,"envKey");const match=ownDataPropertyValue3(value,"match");const placeholder=ownDataPropertyValue3(value,"value");return typeof envKey==="string"&&CREDENTIAL_ENV_NAME_PATTERN.test(envKey)&&match===`^openshell:resolve:env:(v[0-9]+_)?${envKey}$`&&typeof placeholder==="string"&&messagingCredentialPlaceholderEnvKey(placeholder)===envKey}function isAllowedMessagingRuntimeAliasStringPath(path5,allowedAliasIndexes){return path5.length===6&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="runtimeSetup"&&path5[3]==="envAliases"&&allowedAliasIndexes.has(path5[4]??"")&&(path5[5]==="match"||path5[5]==="value")}function isMessagingPackagePin(path5,value){return path5.length===6&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="buildSteps"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")&&path5[4]==="value"&&path5[5]==="pin"&&typeof value==="boolean"}function containsUrlWithCredentialMaterial(value){const candidates=value.match(URL_CANDIDATE_RE)??[];for(let index=0;index{if(isCredentialShapedName(key))credentialQuery=true});const fragment=url.hash.startsWith("#")?url.hash.slice(1):url.hash;const queryStart=fragment.indexOf("?");const fragmentParameters=new URLSearchParams(queryStart>=0?fragment.slice(queryStart+1):fragment);let credentialFragment=false;fragmentParameters.forEach((_fragmentValue,key)=>{if(isCredentialShapedName(key))credentialFragment=true});if(url.username||url.password||credentialQuery||credentialFragment)return true}catch{}}return false}function invalid(reason){throw new ManagedStartupProfileError(reason)}function payloadPath(path5){return path5.reduce((result,segment)=>segment.startsWith("[")?`${result}${segment}`:`${result}${result?".":""}${segment}`,"")}function mapArrayByIndex(values,mapper){const mapped=[];for(let index=0;index0&&values[insertion-1]>selected){Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:values[insertion-1],writable:true});insertion-=1}Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:selected,writable:true})}return values}function requireRecord(value,where){if(!isPlainObject(value))invalid(`${where} must be an object`);return value}function rejectUnknownKeys(value,allowed,where){const keys=Object.keys(value);for(let index=0;indexmaxBytes||CONTROL_CHARACTER_RE.test(value)){invalid(`${where} must be a bounded, non-empty string without control characters`)}return value}function requireStringEnum(value,allowed,where){const normalized=requireBoundedString(value,where);if(!allowed.has(normalized))invalid(`${where} is not supported`);return normalized}function requireNullablePositiveInteger(value,where){if(value===null)return null;if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>MAX_TUNING_INTEGER){invalid(`${where} must be null or a bounded positive integer`)}return value}function requirePositiveInteger(value,where,maximum=MAX_TUNING_INTEGER){if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>maximum){invalid(`${where} must be a bounded positive integer`)}return value}function requirePort(value,where,minimum=1){if(typeof value!=="number"||!Number.isInteger(value)||value<1||value>65535){invalid(`${where} must be a valid TCP port`)}if(valueMAX_LIST_ITEMS){invalid(`${where} must be a bounded string list`)}const items=mapArrayByIndex(value,item=>requireBoundedString(item,`${where} item`));const unique2=new Set;for(let index=0;index{if(depth>MAX_JSON_DEPTH)invalid(`${where} exceeds the JSON depth limit`);if(current===null||typeof current==="string"||typeof current==="boolean"){return current}if(typeof current==="number"){if(!Number.isFinite(current))invalid(`${where} contains a non-finite number`);return current}if(Array.isArray(current)){return mapArrayByIndex(current,item=>clone(item,depth+1))}if(!isPlainObject(current))invalid(`${where} contains a non-JSON value`);const result=options.nullPrototypeObjects?Object.create(null):{};const keys=Object.getOwnPropertyNames(current);for(let index=0;indexMAX_IDENTIFIER_BYTES||CONTROL_CHARACTER_RE.test(key)){invalid(`${where} contains an invalid object key`)}const descriptor=Object.getOwnPropertyDescriptor(current,key);if(!descriptor||!("value"in descriptor)){invalid(`${where} contains a non-JSON value`)}Object.defineProperty(result,key,{configurable:true,enumerable:true,value:clone(descriptor.value,depth+1),writable:true})}return result};return clone(value,0)}function requireJsonObjectOrNull(value,where){if(value===null)return null;if(!isPlainObject(value))invalid(`${where} must be null or a plain JSON object`);return cloneJsonValue(value,where,{nullPrototypeObjects:true})}function requireJsonObject(value,where){const object=requireJsonObjectOrNull(value,where);if(object===null)invalid(`${where} must be a plain JSON object`);return object}function requireHttpUrl(value,where){const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) URL`)}if(parsed.protocol!=="http:"&&parsed.protocol!=="https:"||parsed.username||parsed.password||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) URL without query or fragment data`)}const pathname=parsed.pathname.replace(/\/+$/u,"");return pathname===""?parsed.origin:`${parsed.origin}${pathname}`}function requireProxyUrl(value,allowedSchemes,where){if(value===null)return null;const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) proxy URL`)}if(!allowedSchemes.has(parsed.protocol)||parsed.username||parsed.password||parsed.pathname!=="/"||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) proxy origin`)}return parsed.origin}function requireManagedProxyHost(value,where){const host=requireBoundedString(value,where);if(!/^[A-Za-z0-9._-]+$/u.test(host)){invalid(`${where} must be a hostname or IPv4 address without a scheme or separators`)}return host}function isLoopbackUrl(value){const hostname=new URL(value).hostname.toLowerCase();return hostname==="localhost"||hostname==="127.0.0.1"||hostname==="::1"||hostname==="[::1]"}function configuredDashboardPort(value){const explicit=new URL(value).port;return explicit===""?18789:Number(explicit)}function requireSampleRate(value,where){if(typeof value!=="number"||!Number.isFinite(value)||value<0||value>1){invalid(`${where} must be a number between 0 and 1`)}return value}function assertPayloadStructureAndCredentialShapes(root){const pending=[{value:root,depth:0,path:[]}];const allowedRuntimeAliasIndexes=new Set;const allowedBuildStepPlaceholders=new Set;const selectedAgent=isPlainObject(root)?ownDataPropertyValue3(root,"agent"):void 0;let discoveredNodes=1;let observedBytes=0;const observeText=value=>{observedBytes+=import_node_buffer.Buffer.byteLength(value,"utf8");if(observedBytes>MANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}};const reserveNode=depth=>{discoveredNodes+=1;if(discoveredNodes>MAX_JSON_NODES||depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}observedBytes+=1};while(pending.length>0){const current=pending.pop();if(!current)break;if(current.depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}if(typeof current.value==="string"){observeText(current.value);if(!isAllowedMessagingRuntimeAliasStringPath(current.path,allowedRuntimeAliasIndexes)&&!isMessagingCredentialPlaceholder(current.path,current.value,allowedBuildStepPlaceholders)&&!isMessagingCredentialPlaceholderAssignment(selectedAgent,current.path,current.value)&&(valueLooksLikeSecret(current.value)||containsMessagingCredentialPlaceholder(current.value))){invalid(`payload field ${payloadPath(current.path)} contains credential-shaped string data`)}if(RAW_CA_PEM_RE.test(current.value)||RAW_CA_PEM_BASE64_RE.test(current.value)||RAW_CA_DER_BASE64_RE.test(current.value)||RAW_CA_DATA_URI_RE.test(current.value)){invalid(`payload field ${payloadPath(current.path)} contains raw certificate data; provide only the CA SHA-256 digest`)}if(containsUrlWithCredentialMaterial(current.value)){invalid(`payload field ${payloadPath(current.path)} contains a URL with embedded credentials`)}continue}if(Array.isArray(current.value)){if(Object.getPrototypeOf(current.value)!==Array.prototype){invalid("payload arrays must use the standard JSON prototype")}if("toJSON"in current.value){invalid("payload must not define a custom JSON serializer")}if(Object.getOwnPropertySymbols(current.value).length>0||Object.getOwnPropertyNames(current.value).length!==current.value.length+1){invalid("payload arrays must contain only indexed JSON values")}for(let index=0;index0||discoveredNodes+keys.length>MAX_JSON_NODES){invalid("payload structure exceeds the complexity limit")}for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}}function validateWebSearch(value,agent){const webSearch=requireRecord(value,"agentConfig.webSearch");rejectUnknownKeys(webSearch,WEB_SEARCH_KEYS,"agentConfig.webSearch");const provider=requireStringEnum(webSearch.provider,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].webSearchProviders),"agentConfig.webSearch.provider");return{enabled:requireBoolean(webSearch.enabled,"agentConfig.webSearch.enabled"),provider}}function validateOpenClawOtel(value){const otel=requireRecord(value,"agentConfig.otel");rejectUnknownKeys(otel,OTEL_KEYS,"agentConfig.otel");return{enabled:requireBoolean(otel.enabled,"agentConfig.otel.enabled"),endpointUrl:requireHttpUrl(otel.endpointUrl,"agentConfig.otel.endpointUrl"),serviceName:requireBoundedString(otel.serviceName,"agentConfig.otel.serviceName",MAX_IDENTIFIER_BYTES),sampleRate:requireSampleRate(otel.sampleRate,"agentConfig.otel.sampleRate")}}function validateExtraAgents(value){const extraAgents=requireRecord(value,"agentConfig.extraAgents");rejectUnknownKeys(extraAgents,EXTRA_AGENTS_KEYS,"agentConfig.extraAgents");if(!Array.isArray(extraAgents.agents)||extraAgents.agents.length>MAX_LIST_ITEMS){invalid("agentConfig.extraAgents.agents must be a bounded JSON object list")}return{agents:mapArrayByIndex(extraAgents.agents,(agent,index)=>requireJsonObject(agent,`agentConfig.extraAgents.agents[${String(index)}]`)),defaults:requireJsonObject(extraAgents.defaults,"agentConfig.extraAgents.defaults"),main:requireJsonObject(extraAgents.main,"agentConfig.extraAgents.main")}}function validateDeviceAuth(value){const deviceAuth=requireRecord(value,"agentConfig.deviceAuth");rejectUnknownKeys(deviceAuth,DEVICE_AUTH_KEYS,"agentConfig.deviceAuth");return{disabled:requireBoolean(deviceAuth.disabled,"agentConfig.deviceAuth.disabled"),optOutSource:requireStringEnum(deviceAuth.optOutSource,new Set(["operator","managed-onboard"]),"agentConfig.deviceAuth.optOutSource")}}function validateAgentConfig(value,expectedAgent){const config=requireRecord(value,"agentConfig");const agent=requireStringEnum(config.agent,MANAGED_STARTUP_AGENT_SET,"agentConfig.agent");if(agent!==expectedAgent)invalid("agentConfig.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(config,OPENCLAW_CONFIG_KEYS,"agentConfig");const heartbeatEvery=config.heartbeatEvery===null?null:requireBoundedString(config.heartbeatEvery,"agentConfig.heartbeatEvery",MAX_IDENTIFIER_BYTES);if(heartbeatEvery!==null&&!/^\d+(?:s|m|h)$/u.test(heartbeatEvery)){invalid("agentConfig.heartbeatEvery must be null or a duration ending in s, m, or h")}return{agent,webSearch:validateWebSearch(config.webSearch,agent),otel:validateOpenClawOtel(config.otel),agentTimeoutSeconds:requirePositiveInteger(config.agentTimeoutSeconds,"agentConfig.agentTimeoutSeconds"),heartbeatEvery,extraAgents:validateExtraAgents(config.extraAgents),deviceAuth:validateDeviceAuth(config.deviceAuth),minimalBootstrap:requireBoolean(config.minimalBootstrap,"agentConfig.minimalBootstrap")}}if(agent==="hermes"){rejectUnknownKeys(config,HERMES_CONFIG_KEYS,"agentConfig");return{agent,webSearch:validateWebSearch(config.webSearch,agent)}}if(agent==="pi"){rejectUnknownKeys(config,PI_CONFIG_KEYS,"agentConfig");return{agent}}rejectUnknownKeys(config,DCODE_CONFIG_KEYS,"agentConfig");return{agent,autoApprovalMode:requireStringEnum(config.autoApprovalMode,DCODE_AUTO_APPROVAL_MODE_SET,"agentConfig.autoApprovalMode"),observabilityEnabled:requireBoolean(config.observabilityEnabled,"agentConfig.observabilityEnabled")}}function validateDashboard(value,expectedAgent){const dashboard=requireRecord(value,"dashboard");const agent=requireStringEnum(dashboard.agent,MANAGED_STARTUP_AGENT_SET,"dashboard.agent");if(agent!==expectedAgent)invalid("dashboard.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(dashboard,OPENCLAW_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");const bindAddress=requireStringEnum(dashboard.bindAddress,new Set(["127.0.0.1","0.0.0.0"]),"dashboard.bindAddress");const wslExposure=requireBoolean(dashboard.wslExposure,"dashboard.wslExposure");const hasRemoteExposure=!isLoopbackUrl(url)||bindAddress==="0.0.0.0"||wslExposure;if(mode==="remote"!==hasRemoteExposure){invalid("OpenClaw dashboard.mode must reflect its URL, bind address, and WSL exposure")}const port=requirePort(dashboard.port,"dashboard.port",1024);if(isHermesApiPort(port))invalid(`OpenClaw dashboard.port must not use a reserved Hermes API port (${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END})`);if(configuredDashboardPort(url)!==port){invalid("OpenClaw dashboard.port must match dashboard.url")}return{agent,mode,url,port,bindAddress,wslExposure}}if(agent==="hermes"){rejectUnknownKeys(dashboard,HERMES_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");if(!isLoopbackUrl(url)){invalid("Hermes dashboard.url must remain loopback; OpenShell owns the host forward")}if(mode==="disabled"){if(dashboard.publicPort!==null||dashboard.internalPort!==null||dashboard.tuiEnabled!==false){invalid("disabled Hermes dashboard must not configure ports or TUI")}return{agent,mode,url,publicPort:null,internalPort:null,tuiEnabled:false}}const publicPort=requirePort(dashboard.publicPort,"dashboard.publicPort",1024);const internalPort=requirePort(dashboard.internalPort,"dashboard.internalPort",1024);if(publicPort===internalPort){invalid("Hermes dashboard publicPort and internalPort must differ")}if(isHermesReservedApiPort(publicPort)||isHermesReservedApiPort(internalPort)){invalid(`Hermes dashboard ports must not use reserved API ports ${HERMES_RESERVED_API_PORT_LABEL}`)}if(configuredDashboardPort(url)!==publicPort){invalid("Hermes dashboard.publicPort must match dashboard.url")}return{agent,mode,url,publicPort,internalPort,tuiEnabled:requireBoolean(dashboard.tuiEnabled,"dashboard.tuiEnabled")}}if(agent==="pi"){rejectUnknownKeys(dashboard,PI_DASHBOARD_KEYS,"dashboard");if(dashboard.mode!=="disabled")invalid("pi dashboard.mode must be disabled");return{agent,mode:"disabled"}}rejectUnknownKeys(dashboard,DCODE_DASHBOARD_KEYS,"dashboard");if(dashboard.mode!=="disabled"){invalid("langchain-deepagents-code dashboard.mode must be disabled")}return{agent,mode:"disabled"}}function validateInference(value,agent){const inference=requireRecord(value,"inference");rejectUnknownKeys(inference,INFERENCE_KEYS,"inference");const routeProvider=requireBoundedString(inference.routeProvider,"inference.routeProvider");const upstreamProvider=requireBoundedString(inference.upstreamProvider,"inference.upstreamProvider");const model=requireBoundedString(inference.model,"inference.model",MAX_MODEL_BYTES);const api=requireStringEnum(inference.api,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inferenceApis),"inference.api");const upstreamEndpointUrl=inference.upstreamEndpointUrl===null?null:requireHttpUrl(inference.upstreamEndpointUrl,"inference.upstreamEndpointUrl");const primaryModelRef=inference.primaryModelRef===null?null:requireBoundedString(inference.primaryModelRef,"inference.primaryModelRef",MAX_MODEL_BYTES);const compatibility=requireJsonObjectOrNull(inference.compatibility,"inference.compatibility");const inputModalities=inference.inputModalities===null?null:requireEnumList(inference.inputModalities,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inputModalities),"inference.inputModalities",{allowEmpty:false});if(upstreamEndpointUrl!==null&&!MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].supportsUpstreamEndpoint){invalid(`inference.upstreamEndpointUrl must be null for ${agent}`)}if(agent==="openclaw"){if(primaryModelRef===null||inputModalities===null){invalid("openclaw requires primaryModelRef and inputModalities")}if(primaryModelRef!==`${routeProvider}/${model}`){invalid("openclaw primaryModelRef must match routeProvider and model")}}else{if(primaryModelRef!==null||compatibility!==null||inputModalities!==null){invalid(`${agent} does not support primaryModelRef, compatibility, or inputModalities`)}if(agent==="langchain-deepagents-code"&&!isValidDcodeUpstreamProvider(upstreamProvider)){invalid("inference.upstreamProvider must start with an ASCII letter or digit and contain 1-64 ASCII letters, digits, dots, underscores, or hyphens for DCode")}}return{routeProvider,upstreamProvider,model,routedBaseUrl:requireHttpUrl(inference.routedBaseUrl,"inference.routedBaseUrl"),upstreamEndpointUrl,api,primaryModelRef,compatibility,inputModalities}}function validateProxy(value,agent){const proxy=requireRecord(value,"proxy");rejectUnknownKeys(proxy,PROXY_KEYS,"proxy");const hostHttpUrl=requireProxyUrl(proxy.hostHttpUrl,new Set(["http:"]),"proxy.hostHttpUrl");const hostHttpsUrl=requireProxyUrl(proxy.hostHttpsUrl,new Set(["http:","https:"]),"proxy.hostHttpsUrl");const hostNoProxy=requireStringList(proxy.hostNoProxy,"proxy.hostNoProxy");if(!MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].supportsHostProxyIntent&&(hostHttpUrl!==null||hostHttpsUrl!==null||hostNoProxy.length>0)){invalid(`${agent} rejects host proxy intent and accepts only its root-owned managed route`)}return{managedHost:requireManagedProxyHost(proxy.managedHost,"proxy.managedHost"),managedPort:requirePort(proxy.managedPort,"proxy.managedPort"),hostHttpUrl,hostHttpsUrl,hostNoProxy}}function validateTools(value,agent){const tools=requireRecord(value,"tools");rejectUnknownKeys(tools,TOOLS_KEYS,"tools");const enabledGateways=requireEnumList(tools.enabledGateways,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].toolGateways),"tools.enabledGateways",{allowEmpty:true});return{disclosure:requireStringEnum(tools.disclosure,new Set(["progressive","direct"]),"tools.disclosure"),enabledGateways}}function validateTuning(value,agent){const tuning=requireRecord(value,"tuning");rejectUnknownKeys(tuning,TUNING_KEYS,"tuning");const result={contextWindow:requireNullablePositiveInteger(tuning.contextWindow,"tuning.contextWindow"),maxTokens:requireNullablePositiveInteger(tuning.maxTokens,"tuning.maxTokens"),reasoning:requireNullableBoolean(tuning.reasoning,"tuning.reasoning"),reasoningEffort:tuning.reasoningEffort===null?null:requireStringEnum(tuning.reasoningEffort,REASONING_EFFORT_SET,"tuning.reasoningEffort")};const advertised=new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].tuningFields);const unsupported=TUNING_FIELD_ORDER.filter(field=>result[field]!==null&&!advertised.has(field));if(unsupported.length>0){invalid(`${agent} does not support startup tuning fields: ${unsupported.join(", ")}`)}if(agent==="openclaw"){const missing=TUNING_FIELD_ORDER.filter(field=>advertised.has(field)&&result[field]===null);if(missing.length>0){invalid(`openclaw requires ${missing.join(", ")} tuning`)}}if(agent==="hermes"&&result.contextWindow!==null&&result.contextWindowcanonicalizeJson(item));if(!isPlainObject(value))return value;const result={};const keys=sortStrings(Object.keys(value));for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`canonical payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}return serialized}function decodeManagedStartupProfile(encoded){if(typeof encoded!=="string"||encoded.length===0||import_node_buffer.Buffer.byteLength(encoded,"ascii")>MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES||!BASE64URL_RE.test(encoded)||encoded.length%4===1){invalid("encoded payload is malformed or exceeds the size limit")}const bytes=import_node_buffer.Buffer.from(encoded,"base64url");if(bytes.length===0||bytes.length>MANAGED_STARTUP_PROFILE_MAX_BYTES||bytes.toString("base64url")!==encoded){invalid("encoded payload is malformed or exceeds the size limit")}let raw;try{raw=UTF8_DECODER.decode(bytes)}catch{invalid("payload is not valid UTF-8")}let parsed;try{parsed=JSON.parse(raw)}catch{invalid("payload is not valid JSON")}const profile=validateManagedStartupProfile(parsed);if(serializeManagedStartupProfile(profile)!==raw){invalid("payload is not in canonical form")}return profile}function fingerprintManagedStartupProfile(profile){return(0,import_node_crypto2.createHash)("sha256").update(serializeManagedStartupProfile(profile),"utf8").digest("hex")}var ManagedStartupAgentEnvironmentError=class extends Error{constructor(message){super(`Cannot map managed startup profile: ${message}`);this.name="ManagedStartupAgentEnvironmentError"}};var EMPTY_APPLICATION_ENVIRONMENT=Object.freeze({});var OPENCLAW_APPLICATION_RUNTIME_INPUTS=Object.freeze([["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","positive-safe-integer"],["NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","positive-finite-seconds"]]);function booleanFlag(value){return value?"1":"0"}function canonicalizeJson2(value){if(Array.isArray(value))return value.map(item=>canonicalizeJson2(item));if(value===null||typeof value!=="object")return value;const record=value;return Object.fromEntries(Object.keys(record).sort().map(key=>[key,canonicalizeJson2(record[key])]))}function encodeCanonicalJson(value){return import_node_buffer2.Buffer.from(JSON.stringify(canonicalizeJson2(value)),"utf8").toString("base64")}function sortedEnvironment(environment){return Object.freeze(Object.fromEntries(Object.entries(environment).sort(([left],[right])=>leftright?1:0)))}function canonicalApplicationRuntimeValue(name,raw,kind){if(raw.includes("\0")||/[\r\n]/u.test(raw)){throw new ManagedStartupAgentEnvironmentError(`${name} must be single-line text`)}const value=Number(raw.trim());const valid=kind==="positive-safe-integer"?Number.isSafeInteger(value)&&value>0:Number.isFinite(value)&&value>0;if(!valid){throw new ManagedStartupAgentEnvironmentError(`${name} must be ${kind==="positive-safe-integer"?"a positive safe integer":"finite positive seconds"}`)}return String(value)}function applicationRuntimePlan(profile,environment){const exportEnvironment={};if(profile.agent==="openclaw"){for(const[name,kind]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){const raw=environment[name];if(raw!==void 0){exportEnvironment[name]=canonicalApplicationRuntimeValue(name,raw,kind)}}}const unsetEnvironment=new Set(MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS.filter(({supportedFor})=>!supportedFor.includes(profile.agent)).map(({input})=>input));if(profile.agent!=="openclaw"){for(const[name]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){unsetEnvironment.add(name)}}return Object.freeze({exportEnvironment:sortedEnvironment(exportEnvironment),unsetEnvironment:Object.freeze([...unsetEnvironment].sort())})}function commonConfigurationEnvironment(profile){return{NEMOCLAW_INFERENCE_API:profile.inference.api,NEMOCLAW_INFERENCE_BASE_URL:profile.inference.routedBaseUrl,NEMOCLAW_INFERENCE_PROVIDER_ID:profile.inference.routeProvider,NEMOCLAW_MODEL:profile.inference.model,NEMOCLAW_TOOL_DISCLOSURE:profile.tools.disclosure,NEMOCLAW_UPSTREAM_PROVIDER:profile.inference.upstreamProvider}}function appendHostProxyEnvironment(environment,profile,options={}){if(options.preserveAmbientWhenAbsent===true&&profile.proxy.hostHttpUrl===null&&profile.proxy.hostHttpsUrl===null&&profile.proxy.hostNoProxy.length===0){return}const httpProxy=profile.proxy.hostHttpUrl??"";const httpsProxy=profile.proxy.hostHttpsUrl??"";const noProxy=profile.proxy.hostNoProxy.join(",");environment.HTTP_PROXY=httpProxy;environment.HTTPS_PROXY=httpsProxy;environment.NO_PROXY=noProxy;environment.http_proxy=httpProxy;environment.https_proxy=httpsProxy;environment.no_proxy=noProxy}function messagingEnvironment(profile,expectedAgent){if(profile.messaging.plan===null)return{};const plan=parseSandboxMessagingPlan(profile.messaging.plan,{agent:expectedAgent});if(!plan){throw new ManagedStartupAgentEnvironmentError(`messaging.plan must contain a validated ${expectedAgent} messaging plan`)}const{workflow:_workflow,...imageBuildPlan}=plan;return{NEMOCLAW_MESSAGING_PLAN_B64:encodeCanonicalJson(imageBuildPlan)}}function corporateCaMaterial(profile){return Object.freeze({kind:"corporate-ca-handoff",legacyInput:"NEMOCLAW_CORPORATE_CA_B64",expectedSha256:profile.corporateCa.bundleSha256})}function rootOwnedFile(legacyInput,path5,value){return Object.freeze({kind:"root-owned-file",legacyInput,path:path5,contents:`${value} +var __create=Object.create;var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __getOwnPropNames=Object.getOwnPropertyNames;var __getProtoOf=Object.getPrototypeOf;var __hasOwnProp=Object.prototype.hasOwnProperty;var __export=(target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:true})};var __copyProps=(to,from,except,desc)=>{if(from&&typeof from==="object"||typeof from==="function"){for(let key of __getOwnPropNames(from))if(!__hasOwnProp.call(to,key)&&key!==except)__defProp(to,key,{get:()=>from[key],enumerable:!(desc=__getOwnPropDesc(from,key))||desc.enumerable})}return to};var __toESM=(mod,isNodeMode,target)=>(target=mod!=null?__create(__getProtoOf(mod)):{},__copyProps(isNodeMode||!mod||!mod.__esModule?__defProp(target,"default",{value:mod,enumerable:true}):target,mod));var __toCommonJS=mod=>__copyProps(__defProp({},"__esModule",{value:true}),mod);var image_runtime_exports={};__export(image_runtime_exports,{applyManagedBootstrapEnvelope:()=>applyManagedBootstrapEnvelope,main:()=>main2,managedBootstrapEnvelopeClaimPaths:()=>managedBootstrapEnvelopeClaimPaths,readManagedBootstrapEnvelope:()=>readManagedBootstrapEnvelope,recoverManagedBootstrapEnvelopeClaim:()=>recoverManagedBootstrapEnvelopeClaim,verifyManagedBootstrapImageCompletion:()=>verifyManagedBootstrapImageCompletion,waitForManagedBootstrapImageCompletion:()=>waitForManagedBootstrapImageCompletion});module.exports=__toCommonJS(image_runtime_exports);var import_node_fs4=__toESM(require("node:fs"));var import_node_path4=__toESM(require("node:path"));var import_node_child_process=require("node:child_process");var import_node_crypto6=require("node:crypto");var import_node_fs3=__toESM(require("node:fs"));var import_node_path3=__toESM(require("node:path"));var MAX_CORPORATE_CA_BYTES=128*1024;var PEM_CERTIFICATE_RE_GLOBAL=/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g;var import_node_buffer2=require("node:buffer");function isObjectRecord(value){return typeof value==="object"&&value!==null&&!Array.isArray(value)}var ChannelManifestRegistry=class{manifests=new Map;constructor(manifests=[]){for(const manifest of manifests){this.register(manifest)}}register(manifest){if(this.manifests.has(manifest.id)){throw new Error(`Duplicate channel manifest id '${manifest.id}'`)}this.manifests.set(manifest.id,manifest);return this}get(channelId){return this.manifests.get(channelId)}list(){return Array.from(this.manifests.values())}listAvailable(ctx={}){const supportedChannelIds=Array.isArray(ctx.supportedChannelIds)?new Set(ctx.supportedChannelIds):null;return this.list().filter(manifest=>{if(ctx.agent&&!manifest.supportedAgents.includes(ctx.agent)){return false}if(supportedChannelIds&&!supportedChannelIds.has(manifest.id)){return false}return true})}};function createChannelManifestRegistry(manifests=[]){return new ChannelManifestRegistry(manifests)}var discordManifest={schemaVersion:1,id:"discord",displayName:"Discord",description:"Discord bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"DISCORD_BOT_TOKEN",prompt:{label:"Discord Bot Token",help:"Discord Developer Portal \u2192 Applications \u2192 Bot \u2192 Reset/Copy Token."}},{id:"serverId",kind:"config",required:false,envKey:"DISCORD_SERVER_ID",statePath:"discordGuilds.serverId",prompt:{label:"Discord Server ID (for guild workspace access)",help:"Enable Developer Mode in Discord, then right-click your server and copy the Server ID.",emptyValueMessage:"guild channels stay disabled"}},{id:"requireMention",kind:"config",required:false,envKey:"DISCORD_REQUIRE_MENTION",statePath:"discordGuilds.requireMention",promptWhenInput:"serverId",validValues:["0","1"],defaultValue:"1",prompt:{label:"Discord mention mode",help:"Choose whether the bot should reply only when @mentioned or to all messages in this server."}},{id:"userId",kind:"config",required:false,envKey:"DISCORD_USER_ID",statePath:"discordGuilds.userIds",promptWhenInput:"serverId",prompt:{label:"Discord User ID (optional guild allowlist)",help:"Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.",emptyValueMessage:"any member in the configured server can message the bot"}}],credentials:[{id:"discordBotToken",sourceInput:"botToken",providerName:"{sandboxName}-discord-bridge",providerEnvKey:"DISCORD_BOT_TOKEN",placeholder:"openshell:resolve:env:DISCORD_BOT_TOKEN"}],policyPresets:[{name:"discord",validationWarningLines:["For Discord preset validation, do not use curl as the success signal:","curl is not in the preset binary allowlist, so curl probes can fail even","when the policy is working. Use Node HTTPS against","https://discord.com/api/v10/gateway or validate the configured",'messaging bridge/gateway path. DNS-only checks such as dns.resolve("gateway.discord.gg")',"can also be inconclusive behind a proxy."]}],render:[{id:"discord-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.discord",value:{enabled:true,accounts:{default:{token:"{{credential.discordBotToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},proxy:"{{discordProxyUrl}}",dmPolicy:"{{discord.allowedUsers.dmPolicy}}",allowFrom:"{{discord.allowedUsers.values}}"}}}}},{id:"discord-openclaw-guilds",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{discord.hasGuilds}}",fragment:{path:"channels.discord",value:{groupPolicy:"allowlist",guilds:"{{discord.guilds}}"}}},{id:"discord-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.discord",value:{enabled:true}}},{id:"discord-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["DISCORD_BOT_TOKEN={{credential.discordBotToken.placeholder}}","NEMOCLAW_DISCORD_GUILD_IDS={{discord.guildIds.csv}}","DISCORD_ALLOWED_USERS={{discord.allowedUsers.csv}}","DISCORD_ALLOW_ALL_USERS={{discord.allowAllUsers}}"]},{id:"discord-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"discord",value:{require_mention:"{{discord.requireMention}}",free_response_channels:"",allowed_channels:"",auto_thread:true,reactions:true,channel_prompts:{}}}},{id:"discord-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.discord",value:{enabled:true}}}],runtime:{openclaw:{channelName:"discord",visibility:{configKeys:["discord"],logPatterns:["discord"]}}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/discord@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-tZfdC1YA8oVLvc2BK1w0F6rUljS5ugCOp2uWe0vPsbG1fbzVVIO4V32RoqZznGHe5u2R9u4n1aV5Z/qa1m2oFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/discord/-/discord-2026.7.1.tgz"},required:true}],hooks:[{id:"discord-openclaw-bridge-health",phase:"health-check",handler:"discord.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"discord-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"discord-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"serverId",kind:"config"},{id:"requireMention",kind:"config"},{id:"userId",kind:"config"}]}]};var googlechatManifest={schemaVersion:1,id:"googlechat",displayName:"Google Chat",description:"Google Chat (Chat API) bot messaging (experimental)",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"serviceAccount",kind:"secret",required:true,envKey:"GOOGLECHAT_SERVICE_ACCOUNT",maskCap:40,formatHint:"Paste the entire service-account JSON key on one line (minified) \u2014 the whole downloaded JSON file.",maxTokenAttempts:3,prompt:{label:"Google Chat service account JSON",help:["\u2503 GOOGLE CHAT \u2014 service account key","\u2503","\u2503 Google Cloud Console \u2192 IAM & Admin \u2192 Service Accounts","\u2503 \u2192 your bot's SA \u2192 Keys \u2192 Add key \u2192 Create new key \u2192 JSON","\u2503","\u2503 A .json file downloads. Paste its contents below as ONE line (minified).",""].join("\n")}},{id:"audienceType",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE_TYPE",statePath:"googlechatConfig.audienceType",validValues:["app-url","project-number"],defaultValue:"app-url"},{id:"audience",kind:"config",required:false,envKey:"GOOGLECHAT_AUDIENCE",statePath:"googlechatConfig.audience",prompt:{label:"Google Chat webhook audience",help:"Usually filled automatically from the public tunnel URL. For audienceType 'project-number', enter your GCP project number instead.",emptyValueMessage:"inbound webhook verification will be unconfigured"}},{id:"appPrincipal",kind:"config",required:false,envKey:"GOOGLECHAT_APP_PRINCIPAL",statePath:"googlechatConfig.appPrincipal",formatPattern:"^[0-9]{6,32}$",formatHint:"appPrincipal is the add-on's numeric OAuth client ID (uniqueId, ~21 digits), not an email.",prompt:{label:"Google Chat appPrincipal",help:[" Workspace account \u2192 leave blank, done."," Personal Gmail \u2192 needs the add-on's ~21-digit ID (not an email), stable across rebuilds.",""," If you already know it, paste it at the prompt and you're done."," If not, leave it blank \u2014 the first DM reveals it once the sandbox is live:",""," 1. Watch the gateway log:",' nemoclaw logs --follow | grep "unexpected add-on principal"'," 2. DM the bot once \u2014 it won't reply yet, that's expected. The log prints:"," unexpected add-on principal: "," 3. Save that and rebuild:"," GOOGLECHAT_APP_PRINCIPAL= nemoclaw channels add googlechat"," nemoclaw rebuild --yes"].join("\n"),emptyValueMessage:"Workspace accounts do not need it; personal accounts must set it later"}},{id:"allowFrom",kind:"config",required:false,envKey:"GOOGLECHAT_ALLOWED_USERS",statePath:"allowedIds.googlechat",prompt:{label:"Google Chat DM allowlist (comma-separated)",help:["Optional: restrict who can DM the bot."," OpenClaw: users/NNN (emails ignored)"," Hermes: email (users/NNN ignored)"," Blank: pairing mode (recommended) \u2014 OpenClaw's pairing reply shows your users/NNN"," Filling this switches DM policy to allowlist \u2014 a wrong-form entry is dropped silently, with no pairing code."].join("\n"),emptyValueMessage:"bot will require manual pairing"}},{id:"projectId",kind:"config",required:false,envKey:"GOOGLE_CHAT_PROJECT_ID",statePath:"googlechatConfig.projectId",prompt:{label:"Google Chat GCP project ID (Hermes Pub/Sub pull)",help:"The Google Cloud project that owns the Pub/Sub subscription Hermes pulls Chat events from. OpenClaw ignores this.",emptyValueMessage:"required for the Hermes Google Chat channel"}},{id:"subscriptionName",kind:"config",required:false,envKey:"GOOGLE_CHAT_SUBSCRIPTION_NAME",statePath:"googlechatConfig.subscriptionName",prompt:{label:"Google Chat Pub/Sub subscription (projects/

/subscriptions/)",help:["The pull subscription bound to the Chat events topic. Hermes pulls from it over the Pub/Sub REST API; the gateway-minted token is scoped to both chat.bot and pubsub."," Its topic must grant roles/pubsub.publisher to the app's push account:"," Interactive features service-@gcp-sa-gsuiteaddons.iam.gserviceaccount.com"," Classic bot chat-api-push@system.gserviceaccount.com"," Shown at Chat API \u2192 Configuration \u2192 Connection settings"," Missing it channel connects, no event arrives, Chat says the bot is not responding"].join("\n"),emptyValueMessage:"required for the Hermes Google Chat channel"}}],credentials:[],policyPresets:[{name:"googlechat",policyKeys:["googlechat"],agentPolicyKeys:{hermes:["googlechat_hermes"]}}],render:[{id:"googlechat-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.googlechat",value:{enabled:true,serviceAccountFile:"/nonexistent/googlechat-gateway-minted-no-service-account-file",audienceType:"{{googlechatConfig.audienceType}}",audience:"{{googlechatConfig.audience}}",appPrincipal:"{{googlechatConfig.appPrincipal}}",webhookPath:"/googlechat",healthMonitor:{enabled:false},dm:{policy:"{{allowedIds.googlechat.dmPolicy}}",allowFrom:"{{allowedIds.googlechat.values}}"}}}},{id:"googlechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.googlechat",value:{enabled:true}}},{id:"googlechat-openclaw-gateway-reload-off",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"gateway.reload",value:{mode:"off"}}},{id:"googlechat-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["GOOGLE_CHAT_PROJECT_ID={{googlechatConfig.projectId}}","GOOGLE_CHAT_SUBSCRIPTION_NAME={{googlechatConfig.subscriptionName}}","GOOGLE_CHAT_ALLOWED_USERS={{allowedIds.googlechat.csv}}"]},{id:"googlechat-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.google_chat",value:{enabled:true}}}],runtime:{openclaw:{channelName:"googlechat",visibility:{configKeys:["googlechat"],logPatterns:["googlechat"]},nodePreloads:[{module:"googlechat-trusted-proxy-fetch",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat trusted-proxy-fetch patch (route googleapis via trusted env proxy)",installedMessage:"[channels] Google Chat trusted-proxy-fetch patch installed (NODE_OPTIONS updated)"},{module:"googlechat-outbound-auth",injectInto:["boot"],optional:false,installMessage:"[channels] Installing Google Chat outbound-auth patch (gateway-minted bearer)",installedMessage:"[channels] Google Chat outbound-auth patch installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"-----BEGIN (?:RSA )?PRIVATE KEY-----",message:"[SECURITY] Google Chat service account private key leaked into {path} - refusing to serve",exitCode:78}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/googlechat@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-Dv0xOmcxAThEr6hoK+ioofHNu18hfbIceQrEHX3AHZPpOUiTJvToVpA5eX87NQINewwfSJf0gVhE6kSbSk2Aew=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/googlechat/-/googlechat-2026.7.1.tgz"},required:true},{id:"hermesGooglePubsubPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-cloud-pubsub==2.39.0",required:true},{id:"hermesGoogleApiClientPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-api-python-client==2.194.0",required:true},{id:"hermesGoogleAuthPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"google-auth==2.55.1",required:true}],hooks:[{id:"googlechat-tunnel-audience-gate",phase:"enroll",handler:"googlechat.tunnelAudienceGate",agents:["openclaw"],inputs:["audienceType","audience"],outputs:[{id:"audience",kind:"config"}],onFailure:"skip-channel"},{id:"googlechat-service-account",phase:"enroll",handler:"googlechat.tokenPaste",outputs:[{id:"serviceAccount",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"googlechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowFrom",kind:"config"}]},{id:"googlechat-openclaw-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["openclaw"],outputs:[{id:"appPrincipal",kind:"config"}]},{id:"googlechat-hermes-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["hermes"],outputs:[{id:"projectId",kind:"config"},{id:"subscriptionName",kind:"config"}]}]};var slackRuntimeEnvAliases=[{envKey:"SLACK_BOT_TOKEN",match:"^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$",value:"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",message:"[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias"},{envKey:"SLACK_APP_TOKEN",match:"^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$",value:"xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN",message:"[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias"}];var slackManifest={schemaVersion:1,id:"slack",displayName:"Slack",description:"Slack bot messaging",supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"SLACK_BOT_TOKEN",formatPattern:"^xoxb-[A-Za-z0-9_-]+$",formatHint:"Slack bot tokens start with 'xoxb-' (e.g. xoxb---).",prompt:{label:"Slack Bot Token",help:"Slack API \u2192 Your Apps \u2192 OAuth & Permissions \u2192 Bot User OAuth Token (xoxb-...)."}},{id:"appToken",kind:"secret",required:true,envKey:"SLACK_APP_TOKEN",formatPattern:"^xapp-[A-Za-z0-9_-]+$",formatHint:"Slack app tokens start with 'xapp-' (e.g. xapp----).",prompt:{label:"Slack App Token (Socket Mode)",help:"Slack API \u2192 Your Apps \u2192 Basic Information \u2192 App-Level Tokens (xapp-...)."}},{id:"allowedUsers",kind:"config",required:false,envKey:"SLACK_ALLOWED_USERS",statePath:"allowedIds.slack",prompt:{label:"Slack Member IDs (comma-separated allowlist)",help:"In Slack, open each allowed human user's profile -> More -> Copy member ID. Enter one or more comma-separated member IDs, not the app or bot user ID. Member IDs look like U01ABC2DEF3.",emptyValueMessage:"bot will require manual pairing"}},{id:"allowedChannels",kind:"config",required:false,envKey:"SLACK_ALLOWED_CHANNELS",statePath:"slackConfig.allowedChannels",prompt:{label:"Slack Channel IDs (comma-separated allowlist)",help:"Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.",emptyValueMessage:"channel @mentions stay unrestricted by channel ID"}}],credentials:[{id:"slackBotToken",sourceInput:"botToken",providerName:"{sandboxName}-slack-bridge",providerEnvKey:"SLACK_BOT_TOKEN",placeholder:"xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",primary:true},{id:"slackAppToken",sourceInput:"appToken",providerName:"{sandboxName}-slack-app",providerEnvKey:"SLACK_APP_TOKEN",placeholder:"xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"}],policyPresets:[{name:"slack",requiredAtCreate:true}],render:[{id:"slack-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.slack",value:{enabled:true,accounts:{default:{botToken:"{{credential.slackBotToken.placeholder}}",appToken:"{{credential.slackAppToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},dmPolicy:"{{allowedIds.slack.dmPolicy}}",allowFrom:"{{allowedIds.slack.values}}",groupPolicy:"{{allowedIds.slack.groupPolicy}}",channels:"{{allowedIds.slack.channels}}"}}}}},{id:"slack-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.slack",value:{enabled:true}}},{id:"slack-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["SLACK_BOT_TOKEN={{credential.slackBotToken.placeholder}}","SLACK_APP_TOKEN={{credential.slackAppToken.placeholder}}","SLACK_ALLOWED_USERS={{allowedIds.slack.csv}}","SLACK_ALLOWED_CHANNELS={{slackConfig.allowedChannels.csv}}"]},{id:"slack-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.slack",value:{enabled:true,extra:{rich_blocks:true}}}}],runtime:{openclaw:{channelName:"slack",visibility:{configKeys:["slack"],logPatterns:["slack"]},envAliases:slackRuntimeEnvAliases,nodePreloads:[{module:"slack-channel-guard",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Slack channel guard (unhandled-rejection safety net)",installedMessage:"[channels] Slack channel guard installed (NODE_OPTIONS updated)"}],secretScans:[{path:"/sandbox/.openclaw/openclaw.json",pattern:"(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)",message:"[SECURITY] Slack token leaked into {path} - refusing to serve",exitCode:78}]},hermes:{envAliases:slackRuntimeEnvAliases}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/slack@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-dwVGEVCmoTQrOIeZaSCIOPg8pT7hB883QQEXdp9EZUDzTGuvSc+KxH2iERSOV/59hROQctYdcobGn/vdB1H4XA=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/slack/-/slack-2026.7.1.tgz"},required:true}],hooks:[{id:"slack-socket-mode-gateway-conflict",phase:"pre-enable",handler:"slack.socketModeGatewayConflict",onFailure:"abort"},{id:"slack-openclaw-bridge-health",phase:"health-check",handler:"slack.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"slack-socket-mode-gateway-status",phase:"status",handler:"slack.socketModeGatewayStatus",outputs:[{id:"gatewayOverlaps",kind:"status"}]},{id:"slack-status-health",phase:"status",handler:"slack.statusHealth",providesReadiness:true,agents:["openclaw"],outputs:[{id:"channelHealth",kind:"status"}]},{id:"slack-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true},{id:"appToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"slack-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedUsers",kind:"config"},{id:"allowedChannels",kind:"config"}]},{id:"slack-credential-validation",phase:"reachability-check",handler:"slack.validateCredentials",inputs:["botToken","appToken"],onFailure:"skip-channel"}]};var TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT={channelId:"teams",renderId:"teams-openclaw-channel",hookId:"teams-openclaw-channel",handlerId:"common.staticOutputs",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",configPath:"channels.msteams",webhookPath:"/api/messages"};function authorizeTeamsOpenClawWebhookField(entry){if(!isPlainDataObject(entry))return[];const contract=TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT;if(ownDataPropertyValue(entry,"channelId")!==contract.channelId||ownDataPropertyValue(entry,"renderId")!==contract.renderId||ownDataPropertyValue(entry,"hookId")!==contract.hookId||ownDataPropertyValue(entry,"handler")!==contract.handlerId||ownDataPropertyValue(entry,"kind")!==contract.kind||ownDataPropertyValue(entry,"agent")!==contract.agent||ownDataPropertyValue(entry,"target")!==contract.target||ownDataPropertyValue(entry,"path")!==contract.configPath){return[]}const value=ownDataPropertyValue(entry,"value");if(!isPlainDataObject(value))return[];const webhook=ownDataPropertyValue(value,"webhook");if(!isPlainDataObject(webhook)||!hasExactlyOwnDataProperties(webhook,["path","port"])||!isTcpPort(ownDataPropertyValue(webhook,"port"))||ownDataPropertyValue(webhook,"path")!==contract.webhookPath){return[]}return[{path:["value","webhook"],value:webhook}]}function isPlainDataObject(value){if(value===null||typeof value!=="object"||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function ownDataPropertyValue(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}function hasExactlyOwnDataProperties(value,expected){const actual=Object.getOwnPropertyNames(value).sort();return actual.length===expected.length&&actual.every((key,index)=>key===expected[index])}function isTcpPort(value){return Number.isInteger(value)&&value>=1&&value<=65535}var teamsManifest={schemaVersion:1,id:"teams",displayName:"Microsoft Teams",description:"Microsoft Teams bot messaging (experimental)",enrollmentNotes:["Microsoft Teams requires a public HTTPS webhook endpoint at /api/messages; expose the configured Teams webhook port before installing the Teams app.","Use Azure AD object IDs in TEAMS_ALLOWED_USERS so only authorized users can interact with the bot."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"appId",kind:"config",required:true,envKey:"MSTEAMS_APP_ID",statePath:"teamsConfig.appId",prompt:{label:"Microsoft Teams Client ID",help:"Run `teams app create --endpoint https:///api/messages`, then copy CLIENT_ID."}},{id:"clientSecret",kind:"secret",required:true,envKey:"MSTEAMS_APP_PASSWORD",prompt:{label:"Microsoft Teams Client Secret",help:"Use the CLIENT_SECRET printed by `teams app create`. It is shown once; rotate it in Entra ID if it was lost."}},{id:"tenantId",kind:"config",required:true,envKey:"MSTEAMS_TENANT_ID",statePath:"teamsConfig.tenantId",prompt:{label:"Microsoft Teams Tenant ID",help:"Use the TENANT_ID printed by `teams app create` or shown by `teams status --verbose`."}},{id:"allowedUsers",kind:"config",required:false,envKey:"TEAMS_ALLOWED_USERS",statePath:"allowedIds.teams",prompt:{label:"Microsoft Teams AAD Object IDs (comma-separated allowlist)",help:"Recommended: run `teams status --verbose` and enter the Azure AD object IDs allowed to use the bot."}},{id:"webhookPort",kind:"config",required:false,envKey:"MSTEAMS_PORT",statePath:"teamsConfig.webhookPort",defaultValue:"3978",prompt:{label:"Microsoft Teams webhook port",help:"Local bot webhook port to expose publicly. Defaults to 3978 and serves /api/messages."}},{id:"requireMention",kind:"config",required:false,envKey:"TEAMS_REQUIRE_MENTION",statePath:"teamsConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Microsoft Teams mention mode",help:"Controls OpenClaw group and channel behavior only. Direct messages are unaffected."}}],credentials:[{id:"teamsClientSecret",sourceInput:"clientSecret",providerName:"{sandboxName}-teams-bridge",providerEnvKey:"MSTEAMS_APP_PASSWORD",placeholder:"openshell:resolve:env:MSTEAMS_APP_PASSWORD",primary:true}],policyPresets:[{name:"teams",policyKeys:["teams"]}],hostForward:{port:"{{teamsConfig.webhookPort}}",label:"Microsoft Teams webhook"},render:[{id:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.renderId,kind:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.kind,agent:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.agent,target:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.target,fragment:{path:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.configPath,value:{enabled:true,appId:"{{teamsConfig.appId}}",appPassword:"{{credential.teamsClientSecret.placeholder}}",tenantId:"{{teamsConfig.tenantId}}",webhook:{port:"{{teamsConfig.webhookPort}}",path:TEAMS_OPENCLAW_WEBHOOK_RENDER_CONTRACT.webhookPath},healthMonitor:{enabled:false},streaming:{mode:"off"},dmPolicy:"{{allowedIds.teams.dmPolicy}}",allowFrom:"{{allowedIds.teams.values}}",groupPolicy:"open",requireMention:"{{teamsConfig.requireMention}}"}}},{id:"teams-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.msteams",value:{enabled:true}}},{id:"teams-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TEAMS_CLIENT_ID={{teamsConfig.appId}}","TEAMS_CLIENT_SECRET={{credential.teamsClientSecret.placeholder}}","TEAMS_TENANT_ID={{teamsConfig.tenantId}}","TEAMS_ALLOWED_USERS={{allowedIds.teams.csv}}","TEAMS_PORT={{teamsConfig.webhookPort}}"]},{id:"teams-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.teams",value:{enabled:true}}}],runtime:{openclaw:{channelName:"msteams",visibility:{configKeys:["msteams"],logPatterns:["msteams","teams"]},nodePreloads:[{module:"msteams-message-hints",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Microsoft Teams message hint patch (native mentions)",installedMessage:"[channels] Microsoft Teams message hint patch installed (NODE_OPTIONS updated)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/msteams@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-gG/Yk6HZAguHwrmKjsqdONbFz5WNy126PEAXQWNW/TulO1kIifQ6tktM16BQPNLnkmWqLbj+TrrO55Cjas1aFg=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.7.1.tgz"},required:true},{id:"hermesTeamsAppsPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"microsoft-teams-apps==2.0.13.4",required:true},{id:"hermesAiohttpPackage",agent:"hermes",manager:"hermes-uv-pip",spec:"aiohttp==3.14.3",required:true}],hooks:[{id:"teams-host-forward-port-conflict",phase:"pre-enable",handler:"teams.hostForwardPortConflict",inputs:["webhookPort"],onFailure:"abort"},{id:"teams-host-forward-port-status",phase:"status",handler:"teams.hostForwardPortStatus",outputs:[{id:"hostForwardPortOverlaps",kind:"status"}]},{id:"teams-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"clientSecret",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"teams-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"appId",kind:"config",required:true},{id:"tenantId",kind:"config",required:true},{id:"allowedUsers",kind:"config"},{id:"webhookPort",kind:"config"},{id:"requireMention",kind:"config"}]}]};var telegramManifest={schemaVersion:1,id:"telegram",displayName:"Telegram",description:"Telegram bot messaging",diagnosticsProbe:"log-tail",enrollmentNotes:["For Telegram group chats, disable privacy mode in @BotFather (/setprivacy -> your bot -> Disable).","After changing privacy mode, remove and re-add the bot to each group before testing @mentions."],supportedAgents:["openclaw","hermes"],auth:{mode:"token-paste"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"TELEGRAM_BOT_TOKEN",prompt:{label:"Telegram Bot Token",help:"Create a bot via @BotFather on Telegram, then copy the token."}},{id:"allowedIds",kind:"config",required:false,envKey:"TELEGRAM_ALLOWED_IDS",statePath:"allowedIds.telegram",prompt:{label:"Telegram User ID (for DM access)",help:"Send /start to @userinfobot on Telegram to get your numeric user ID.",emptyValueMessage:"bot will require manual pairing"}},{id:"requireMention",kind:"config",required:false,envKey:"TELEGRAM_REQUIRE_MENTION",statePath:"telegramConfig.requireMention",validValues:["0","1"],defaultValue:"1",prompt:{label:"Telegram group mention mode",help:"Controls Telegram group-chat behavior only \u2014 reply only when @mentioned vs. to all group messages. Direct messages are unaffected by this setting and remain subject to pairing and TELEGRAM_ALLOWED_IDS."}},{id:"groupPolicy",kind:"config",required:false,envKey:"TELEGRAM_GROUP_POLICY",statePath:"telegramConfig.groupPolicy",validValues:["open","allowlist","disabled"],defaultValue:"open",prompt:{label:"Telegram group policy",help:"Controls OpenClaw Telegram group access. Hermes does not expose an equivalent disable-groups policy."}}],credentials:[{id:"telegramBotToken",sourceInput:"botToken",providerName:"{sandboxName}-telegram-bridge",providerEnvKey:"TELEGRAM_BOT_TOKEN",placeholder:"openshell:resolve:env:TELEGRAM_BOT_TOKEN"}],policyPresets:[{name:"telegram",policyKeys:["telegram_bot"],agentPolicyKeys:{hermes:["telegram"]}}],render:[{id:"telegram-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.telegram",value:{enabled:true,accounts:{default:{botToken:"{{credential.telegramBotToken.placeholder}}",enabled:true,healthMonitor:{enabled:false},proxy:"{{proxyUrl}}",groupPolicy:"{{telegramConfig.groupPolicy}}",dmPolicy:"{{allowedIds.telegram.dmPolicy}}",allowFrom:"{{allowedIds.telegram.values}}"}}}}},{id:"telegram-openclaw-groups",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",when:"{{telegramConfig.openclawGroups}}",fragment:{path:"channels.telegram.groups",value:"{{telegramConfig.openclawGroups}}"}},{id:"telegram-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.telegram",value:{enabled:true}}},{id:"telegram-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["TELEGRAM_BOT_TOKEN={{credential.telegramBotToken.placeholder}}","TELEGRAM_ALLOWED_USERS={{allowedIds.telegram.csv}}"]},{id:"telegram-hermes-config",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"telegram",value:{require_mention:"{{telegramConfig.requireMention}}"}}},{id:"telegram-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.telegram",value:{enabled:true}}}],runtime:{openclaw:{channelName:"telegram",visibility:{configKeys:["telegram"],logPatterns:["telegram"]},nodePreloads:[{module:"telegram-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing Telegram diagnostics (provider readiness + inference errors)",installedMessage:"[channels] Telegram diagnostics installed (NODE_OPTIONS updated)"}]}},hooks:[{id:"telegram-token-paste",phase:"enroll",handler:"common.tokenPaste",outputs:[{id:"botToken",kind:"secret",required:true}],onFailure:"skip-channel"},{id:"telegram-allowlist-aliases",phase:"enroll",handler:"telegram.allowlistAliases",outputs:[{id:"allowedIds",kind:"config"}]},{id:"telegram-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"requireMention",kind:"config"},{id:"allowedIds",kind:"config"}]},{id:"telegram-openclaw-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["openclaw"],outputs:[{id:"groupPolicy",kind:"config"}]},{id:"telegram-get-me-reachability",phase:"reachability-check",handler:"telegram.getMeReachability",inputs:["botToken"],onFailure:"skip-channel"},{id:"telegram-openclaw-bridge-health",phase:"health-check",handler:"telegram.openclawBridgeHealth",agents:["openclaw"],onFailure:"abort"},{id:"telegram-gateway-conflict-status",phase:"status",handler:"telegram.gatewayConflictStatus",outputs:[{id:"bridgeHealth",kind:"status"}]},{id:"telegram-status-health",phase:"status",handler:"telegram.statusHealth",agents:["openclaw"],outputs:[{id:"channelHealth",kind:"status"}]}]};var WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT={channelId:"wechat",planHookId:"wechat-seed-openclaw-account",handlerId:"wechat.seedOpenClawAccount",outputId:"openclawWeixinAccountFile",kind:"build-file",required:true,mode:"0600"};var WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.handlerId;var WECHAT_SEED_OPENCLAW_ACCOUNT_PLAN_HOOK_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.planHookId;var WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.outputId;var WECHAT_TOKEN_PLACEHOLDER="openshell:resolve:env:WECHAT_BOT_TOKEN";function authorizeWechatAccountFilePlaceholders(value){const content=isPlainDataObject2(value)?ownDataPropertyValue2(value,"content"):void 0;if(!isPlainDataObject2(value)||!hasExactlyOwnDataProperties2(value,["content","mode","path"])||!isWechatAccountFilePath(ownDataPropertyValue2(value,"path"))||ownDataPropertyValue2(value,"mode")!==WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.mode||!isPlainDataObject2(content)||!hasOnlyOwnDataProperties(content,["baseUrl","savedAt","token","userId"])||!hasOwnDataProperty(content,"savedAt")||!hasOwnDataProperty(content,"token")||ownDataPropertyValue2(content,"token")!==WECHAT_TOKEN_PLACEHOLDER||!isNonEmptyString(ownDataPropertyValue2(content,"savedAt"))||!isOptionalNonEmptyString(content,"baseUrl")||!isOptionalNonEmptyString(content,"userId")){return[]}return[{path:["content","token"],value:WECHAT_TOKEN_PLACEHOLDER}]}function isWechatAccountFilePath(value){if(typeof value!=="string")return false;const prefix="openclaw-weixin/accounts/";const suffix=".json";if(!value.startsWith(prefix)||!value.endsWith(suffix))return false;const accountId=value.slice(prefix.length,-suffix.length);return accountId===accountId.trim()&&isSafeWechatAccountId(accountId)}function isSafeWechatAccountId(accountId){return accountId.length>0&&accountId!=="."&&accountId!==".."&&!/[\\/\0-\x1F\x7F]/.test(accountId)&&!accountId.includes("..")}function isPlainDataObject2(value){if(value===null||typeof value!=="object"||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function ownDataPropertyValue2(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}function hasOwnDataProperty(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor!==void 0&&"value"in descriptor}function hasExactlyOwnDataProperties2(value,expected){const actual=Object.getOwnPropertyNames(value).sort();return actual.length===expected.length&&actual.every((key,index)=>key===expected[index])}function hasOnlyOwnDataProperties(value,allowed){return Object.getOwnPropertyNames(value).every(key=>allowed.includes(key))}function isNonEmptyString(value){return typeof value==="string"&&value.length>0}function isOptionalNonEmptyString(value,key){return!hasOwnDataProperty(value,key)||isNonEmptyString(ownDataPropertyValue2(value,key))}var wechatManifest={schemaVersion:1,id:"wechat",displayName:"WeChat",description:"WeChat (personal) bot messaging",enrollmentHelp:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only.",supportedAgents:["openclaw","hermes"],auth:{mode:"host-qr"},inputs:[{id:"botToken",kind:"secret",required:true,envKey:"WECHAT_BOT_TOKEN",prompt:{label:"WeChat Bot Token",help:"Captured automatically via a host-side QR scan during onboard \u2014 pair the bot by scanning the QR with WeChat on your phone (Discover \u2192 Scan). DM-only."}},{id:"accountId",kind:"config",required:true,envKey:"WECHAT_ACCOUNT_ID",statePath:"wechatConfig.accountId"},{id:"baseUrl",kind:"config",required:false,envKey:"WECHAT_BASE_URL",statePath:"wechatConfig.baseUrl"},{id:"userId",kind:"config",required:false,envKey:"WECHAT_USER_ID",statePath:"wechatConfig.userId"},{id:"allowedIds",kind:"config",required:false,envKey:"WECHAT_ALLOWED_IDS",statePath:"allowedIds.wechat",prompt:{label:"WeChat User ID(s) (DM allowlist)",help:"Optional: restrict who can DM the bot. The WeChat user id of the operator who scanned is added automatically; supply additional ids as a comma-separated list.",emptyValueMessage:"bot will require manual pairing"}}],credentials:[{id:"wechatBotToken",sourceInput:"botToken",providerName:"{sandboxName}-wechat-bridge",providerEnvKey:"WECHAT_BOT_TOKEN",placeholder:"openshell:resolve:env:WECHAT_BOT_TOKEN"}],policyPresets:[{name:"wechat",policyKeys:["wechat_bridge"]}],render:[{id:"wechat-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.openclaw-weixin",value:{enabled:true}}},{id:"wechat-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.openclaw-weixin",value:{}}},{id:"wechat-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WEIXIN_TOKEN={{credential.wechatBotToken.placeholder}}","WEIXIN_ACCOUNT_ID={{wechatConfig.accountId}}","WEIXIN_BASE_URL={{wechatConfig.baseUrl}}","WEIXIN_ALLOWED_USERS={{allowedIds.wechat.csv}}"]},{id:"wechat-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.weixin",value:{enabled:true}}}],runtime:{openclaw:{channelName:"openclaw-weixin",visibility:{configKeys:["openclaw-weixin"],logPatterns:["wechat","openclaw-weixin"]},nodePreloads:[{module:"wechat-diagnostics",injectInto:["boot","connect"],optional:false,installMessage:"[channels] Installing WeChat diagnostics (provider readiness + inference errors)",installedMessage:"[channels] WeChat diagnostics installed (NODE_OPTIONS updated)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@tencent-weixin/openclaw-weixin@2.4.3",pin:true,integrity:"sha512-dPQbidUNWigC6V10vGW4i+GLH09x+6zUhafZRjuxkJ9GDu8o62WBsnUTojp4KqUH756hz+t2v9khiCRSi0dBDw==",tarballUrl:"https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/-/openclaw-weixin-2.4.3.tgz",runtimeLock:{cachePath:"/usr/local/share/nemoclaw/wechat-npm-cache",installCacheEnvKey:"NEMOCLAW_WECHAT_NPM_INSTALL_CACHE",lockFile:"/usr/local/lib/nemoclaw/wechat-runtime/package-lock.json",projectsRoot:"/sandbox/.openclaw/npm/projects",verifierPath:"/usr/local/lib/nemoclaw/verify-wechat-runtime-lock.mts",offline:true,legacyPeerDeps:true},required:true}],hooks:[{id:"wechat-host-qr",phase:"enroll",handler:"wechat.ilinkLogin",inputs:["allowedIds"],outputs:[{id:"botToken",kind:"secret",required:true},{id:"accountId",kind:"config",required:true},{id:"baseUrl",kind:"config"},{id:"userId",kind:"config"},{id:"allowedIds",kind:"config"}],onFailure:"skip-channel"},{id:"wechat-config-prompt",phase:"enroll",handler:"common.configPrompt",outputs:[{id:"allowedIds",kind:"config"}]},{id:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.planHookId,phase:"post-agent-install",handler:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.handlerId,agents:["openclaw"],inputs:["wechatConfig.accountId","wechatConfig.baseUrl","wechatConfig.userId","credential.wechatBotToken.placeholder"],outputs:[{id:"openclawWeixinAccountsIndex",kind:"build-file",required:true},{id:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.outputId,kind:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.kind,required:WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.required},{id:"openclawConfigPatch",kind:"build-file",required:true}],onFailure:"abort"},{id:"wechat-health-check",phase:"health-check",handler:"wechat.healthCheck",inputs:["wechatConfig.accountId"],onFailure:"abort"}]};var whatsappManifest={schemaVersion:1,id:"whatsapp",displayName:"WhatsApp",description:"WhatsApp Web messaging (QR pairing)",enrollmentHelp:"WhatsApp Web pairs via QR code scanned with your phone \u2014 no host-side token. After the sandbox is running, run `openshell term` and then use `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes to display the QR.",enrollmentNotes:["After pairing, run `nemoclaw channels status --channel whatsapp`. OpenClaw reports inbound delivery evidence; Hermes reports gateway and dashboard session-path diagnostics."],supportedAgents:["openclaw","hermes"],auth:{mode:"in-sandbox-qr"},inputs:[{id:"mode",kind:"config",required:false,envKey:"WHATSAPP_MODE",statePath:"whatsappConfig.mode",validValues:["self-chat","bot"],defaultValue:"self-chat",prompt:{label:"WhatsApp reply mode",help:"self-chat replies only to messages the paired account sends to itself. bot replies to other senders and stops replying to that self-chat: an unknown sender receives a pairing code you approve with `hermes pairing approve whatsapp `, unless you set WHATSAPP_ALLOWED_IDS to a fixed sender list before this command.",emptyValueMessage:"the sandbox replies only in your own self-chat"}},{id:"allowedIds",kind:"config",required:false,envKey:"WHATSAPP_ALLOWED_IDS",statePath:"allowedIds.whatsapp"}],credentials:[],policyPresets:["whatsapp"],render:[{id:"whatsapp-openclaw-channel",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"channels.whatsapp",value:{enabled:true,accounts:{default:{enabled:true,healthMonitor:{enabled:false}}}}}},{id:"whatsapp-openclaw-plugin",kind:"json-fragment",agent:"openclaw",target:"openclaw.json",fragment:{path:"plugins.entries.whatsapp",value:{enabled:true}}},{id:"whatsapp-hermes-env",kind:"env-lines",agent:"hermes",target:"~/.hermes/.env",lines:["WHATSAPP_ENABLED=true","WHATSAPP_MODE={{whatsappConfig.mode}}","WHATSAPP_DM_POLICY={{whatsappConfig.dmPolicy}}","WHATSAPP_ALLOWED_USERS={{allowedIds.whatsapp.csv}}"]},{id:"whatsapp-hermes-platform",kind:"json-fragment",agent:"hermes",target:"~/.hermes/config.yaml",fragment:{path:"platforms.whatsapp",value:{enabled:true}}}],runtime:{openclaw:{channelName:"whatsapp",visibility:{configKeys:["whatsapp"],logPatterns:["whatsapp"]},nodePreloads:[{module:"whatsapp-qr-compact",injectInto:["connect"],optional:true,installMessage:"[channels] Installing WhatsApp compact-QR renderer (scan-friendly pairing)"}]}},agentPackages:[{id:"openclawPluginPackage",agent:"openclaw",manager:"openclaw-plugin",spec:"npm:@openclaw/whatsapp@{{openclaw.version}}",pin:true,integrityByVersion:{"2026.7.1":"sha512-wLY/Omc5fleRpl2lKGN8sxt/8hYfHGwLRezmWsk8oCbea5pRKUPE6ZX+wJO1O52NOJkAGCuiXvS7x0qIeKxXbQ=="},tarballUrlByVersion:{"2026.7.1":"https://registry.npmjs.org/@openclaw/whatsapp/-/whatsapp-2026.7.1.tgz"},required:true}],hooks:[{id:"whatsapp-config-prompt",phase:"enroll",handler:"common.configPrompt",agents:["hermes"],outputs:[{id:"mode",kind:"config"}]},{id:"whatsapp-status-health",phase:"status",handler:"whatsapp.statusHealth",agents:["openclaw","hermes"],outputs:[{id:"channelHealth",kind:"status"}]}]};var BUILT_IN_CHANNEL_MANIFESTS=[telegramManifest,discordManifest,wechatManifest,slackManifest,whatsappManifest,teamsManifest,googlechatManifest];function createBuiltInChannelManifestRegistry(){return createChannelManifestRegistry(BUILT_IN_CHANNEL_MANIFESTS)}var EXACT_TEMPLATE_PATTERN=/^\{\{\s*([^}]+?)\s*\}\}$/;var TEMPLATE_REFERENCE_PATTERN=/\{\{\s*([^}]+?)\s*\}\}/g;function resolvedRenderTemplateReference(value){return{matched:true,value}}function resolveSandboxNameTemplate(value,sandboxName){return value.replaceAll("{sandboxName}",sandboxName)}function resolveRenderTemplatesInValue(value,context){if(typeof value==="string")return resolveRenderTemplatesInString(value,context);if(Array.isArray(value)){if(value.length===0)return value;const resolved=value.map(entry=>resolveRenderTemplatesInValue(entry,context)).filter(entry=>entry!==void 0);return resolved.length>0?resolved:void 0}if(value&&typeof value==="object"){const sourceEntries=Object.entries(value);if(sourceEntries.length===0)return value;const entries=sourceEntries.map(([key,entry])=>[key,resolveRenderTemplatesInValue(entry,context)]).filter(entry=>entry[1]!==void 0);return entries.length>0?Object.fromEntries(entries):void 0}return value}function isTruthyRenderTemplate(value,context){if(!value)return true;const resolved=resolveRenderTemplatesInString(value,context);if(resolved===void 0||resolved===null||resolved===false)return false;if(Array.isArray(resolved))return resolved.length>0;if(typeof resolved==="object")return Object.keys(resolved).length>0;if(typeof resolved==="string")return resolved.trim().length>0;return true}function resolveRenderTemplatesInString(value,context){const exact=value.match(EXACT_TEMPLATE_PATTERN);if(exact?.[1])return resolveTemplateReference(exact[1].trim(),context);let omitted=false;const resolved=value.replace(TEMPLATE_REFERENCE_PATTERN,(match,reference)=>{const replacement=resolveTemplateReference(reference.trim(),context);if(replacement===void 0||replacement===null){omitted=true;return""}if(Array.isArray(replacement))return replacement.map(String).join(",");if(typeof replacement==="object")return JSON.stringify(replacement);return String(replacement)});return omitted?void 0:resolved}function resolveTemplateReference(reference,context){const resolved=context.referenceResolver?.(reference,context);return resolved?.matched?resolved.value:"{{"+reference+"}}"}function allowedIds(context,channel){return parseList(stateValue(context,`allowedIds.${channel}`))}function stateValue(context,path5){const stateInput=context.inputs.find(input=>input.statePath===path5);if(stateInput?.value!==void 0)return stateInput.value;const inputId=path5.split(".").at(-1);return context.inputs.find(input=>input.inputId===inputId)?.value}function parseList(value){if(Array.isArray(value))return unique(value.map(String).map(cleanString).filter(Boolean));const text=cleanString(value);if(!text)return[];return unique(text.split(",").map(cleanString).filter(Boolean))}function parseBoolean(value){if(typeof value==="boolean")return value;const text=cleanString(value)?.toLowerCase();if(text==="1"||text==="true"||text==="yes"||text==="on")return true;if(text==="0"||text==="false"||text==="no"||text==="off")return false;return void 0}function nonEmptyString(value){return cleanString(value)||void 0}function cleanString(value){const text=String(value??"");if(/[\r\n]/.test(text)){throw new Error("Messaging template values must not contain line breaks.")}return text.trim()}function nonEmptyArray(values){return values.length>0?[...values]:void 0}function nonEmptyCsv(values){return values.length>0?values.join(","):void 0}function nonEmptyObject(value){return Object.keys(value).length>0?value:void 0}function unique(values){return[...new Set(values)]}var resolveDiscordTemplateReference=(reference,context)=>{if(reference==="discordProxyUrl")return resolvedRenderTemplateReference(void 0);switch(reference){case"discord.guilds":return resolvedRenderTemplateReference(nonEmptyObject(discordGuilds(context)));case"discord.hasGuilds":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0);case"discord.guildIds.csv":return resolvedRenderTemplateReference(nonEmptyCsv(Object.keys(discordGuilds(context))));case"discord.allowedUsers.values":return resolvedRenderTemplateReference(nonEmptyArray(discordAllowedUsers(context)));case"discord.allowedUsers.csv":return resolvedRenderTemplateReference(nonEmptyCsv(discordAllowedUsers(context)));case"discord.allowedUsers.dmPolicy":return resolvedRenderTemplateReference(discordAllowedUsers(context).length>0?"allowlist":void 0);case"discord.allowAllUsers":return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length>0&&discordAllowedUsers(context).length===0?true:void 0);case"discord.requireMention":return resolvedRenderTemplateReference(discordRequireMention(context));default:return void 0}};function discordGuilds(context){const serverIds=parseList(stateValue(context,"discordGuilds.serverId"));if(serverIds.length===0)return{};const users=parseList(stateValue(context,"discordGuilds.userIds"));const requireMention=parseBoolean(stateValue(context,"discordGuilds.requireMention"))??true;return Object.fromEntries(serverIds.map(serverId=>[serverId,{requireMention,...users.length>0?{users}:{}}]))}function discordAllowedUsers(context){const users=new Set(allowedIds(context,"discord"));for(const guild of Object.values(discordGuilds(context))){for(const user of guild.users??[])users.add(String(user))}return[...users]}function discordRequireMention(context){for(const guild of Object.values(discordGuilds(context))){if(typeof guild.requireMention==="boolean")return guild.requireMention}return true}var DEFAULT_AUDIENCE_TYPE="app-url";var APP_PRINCIPAL_DISCOVERY_SENTINEL="000000000000000000000";var resolveGooglechatTemplateReference=(reference,context)=>{switch(reference){case"googlechatConfig.audienceType":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audienceType"))??DEFAULT_AUDIENCE_TYPE);case"googlechatConfig.audience":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.audience")));case"googlechatConfig.appPrincipal":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.appPrincipal"))??APP_PRINCIPAL_DISCOVERY_SENTINEL);case"googlechatConfig.projectId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.projectId")));case"googlechatConfig.subscriptionName":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"googlechatConfig.subscriptionName")));default:break}const allowReference=reference.match(/^allowedIds[.]googlechat[.](values|dmPolicy|csv)$/);if(!allowReference?.[1])return void 0;const ids=allowedIds(context,"googlechat");switch(allowReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);case"csv":return resolvedRenderTemplateReference(ids.length>0?ids.join(","):void 0);default:return void 0}};var resolveSlackTemplateReference=(reference,context)=>{if(reference==="slackConfig.allowedChannels.csv"){return resolvedRenderTemplateReference(nonEmptyCsv(slackAllowedChannels(context)))}const allowedIdsReference=reference.match(/^allowedIds[.]slack[.](values|csv|dmPolicy|groupPolicy|channels)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"slack");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);case"groupPolicy":return resolvedRenderTemplateReference(ids.length>0||slackAllowedChannels(context).length>0?"allowlist":void 0);case"channels":return resolvedRenderTemplateReference(slackChannelConfig(context,ids));default:return void 0}};function slackChannelConfig(context,users){const allowedChannels=slackAllowedChannels(context);const entry={enabled:true,requireMention:true,...users.length>0?{users:[...users]}:{}};if(allowedChannels.length>0){return Object.fromEntries(allowedChannels.map(channelId=>[channelId,{...entry}]))}return users.length>0?{"*":entry}:void 0}function slackAllowedChannels(context){return parseList(stateValue(context,"slackConfig.allowedChannels"))}var DEFAULT_TEAMS_WEBHOOK_PORT=3978;var resolveTeamsTemplateReference=(reference,context)=>{switch(reference){case"teamsConfig.appId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.appId")));case"teamsConfig.tenantId":return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"teamsConfig.tenantId")));case"teamsConfig.webhookPort":return resolvedRenderTemplateReference(teamsWebhookPort(context));case"teamsConfig.requireMention":return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"teamsConfig.requireMention")));default:break}const allowedIdsReference=reference.match(/^allowedIds[.]teams[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"teams");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function teamsWebhookPort(context){const raw=nonEmptyString(stateValue(context,"teamsConfig.webhookPort"));if(!raw)return DEFAULT_TEAMS_WEBHOOK_PORT;const port=Number(raw);if(!Number.isInteger(port)||port<1||port>65535){throw new Error("Microsoft Teams webhook port must be an integer TCP port between 1 and 65535.")}return port}var DEFAULT_PROXY_HOST="10.200.0.1";var DEFAULT_PROXY_PORT="3128";var DEFAULT_TELEGRAM_GROUP_POLICY="open";var TELEGRAM_GROUP_POLICIES=new Set(["open","allowlist","disabled"]);var resolveTelegramTemplateReference=(reference,context)=>{if(reference==="proxyUrl")return resolvedRenderTemplateReference(proxyUrl(context.env));if(reference==="telegramConfig.groupPolicy"){return resolvedRenderTemplateReference(telegramGroupPolicy(context))}if(reference==="telegramConfig.openclawGroups"){return resolvedRenderTemplateReference(telegramOpenClawGroups(context))}if(reference==="telegramConfig.requireMention"){return resolvedRenderTemplateReference(parseBoolean(stateValue(context,"telegramConfig.requireMention")))}const allowedIdsReference=reference.match(/^allowedIds[.]telegram[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"telegram");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function proxyUrl(env){const host=nonEmptyString(env?.NEMOCLAW_PROXY_HOST)??DEFAULT_PROXY_HOST;const port=nonEmptyString(env?.NEMOCLAW_PROXY_PORT)??DEFAULT_PROXY_PORT;return`http://${host}:${port}`}function telegramGroupPolicy(context){const value=nonEmptyString(stateValue(context,"telegramConfig.groupPolicy"));return value&&TELEGRAM_GROUP_POLICIES.has(value)?value:DEFAULT_TELEGRAM_GROUP_POLICY}function telegramOpenClawGroups(context){if(telegramGroupPolicy(context)!=="open")return void 0;const requireMention=parseBoolean(stateValue(context,"telegramConfig.requireMention"));return requireMention===true?{"*":{requireMention:true}}:void 0}var WECHAT_ILINK_HOSTS=new Set(["ilinkai.weixin.qq.com","ilinkai.wechat.com"]);var WECHAT_ILINK_IDC_HOST_PATTERN=/^idc-[0-9]+[.]weixin[.]qq[.]com$/;function normalizeWechatIlinkBaseUrl(value){const raw=String(value??"");if(/[\r\n]/.test(raw)){throw new Error("WeChat baseUrl must not contain line breaks.")}const text=raw.trim();if(!text)return void 0;let url;try{url=new URL(text)}catch{throw new Error("WeChat baseUrl must be a valid URL.")}if(url.protocol!=="https:"){throw new Error("WeChat baseUrl must use HTTPS.")}if(url.username||url.password){throw new Error("WeChat baseUrl must not include credentials.")}if(!isWechatIlinkHost(url.hostname)){throw new Error("WeChat baseUrl must use an expected iLink host.")}if(url.pathname&&url.pathname!=="/"||url.search||url.hash){throw new Error("WeChat baseUrl must be an iLink origin URL.")}return url.origin}function isWechatIlinkHost(hostname){const normalized=hostname.toLowerCase();return WECHAT_ILINK_HOSTS.has(normalized)||WECHAT_ILINK_IDC_HOST_PATTERN.test(normalized)}var resolveWechatTemplateReference=(reference,context)=>{const wechatConfig=reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/);if(wechatConfig?.[1]){if(wechatConfig[1]==="baseUrl"){return resolvedRenderTemplateReference(normalizeWechatIlinkBaseUrl(stateValue(context,"wechatConfig.baseUrl")))}return resolvedRenderTemplateReference(nonEmptyString(stateValue(context,"wechatConfig."+wechatConfig[1])))}const allowedIdsReference=reference.match(/^allowedIds[.]wechat[.](values|csv|dmPolicy)$/);if(!allowedIdsReference?.[1])return void 0;const ids=wechatAllowedIds(context);switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));case"dmPolicy":return resolvedRenderTemplateReference(ids.length>0?"allowlist":void 0);default:return void 0}};function wechatAllowedIds(context){const ids=allowedIds(context,"wechat");const userId=nonEmptyString(stateValue(context,"wechatConfig.userId"));return userId&&!ids.includes(userId)?[userId,...ids]:ids}var DEFAULT_WHATSAPP_MODE="self-chat";var BOT_WHATSAPP_MODE="bot";var WHATSAPP_MODES=new Set([DEFAULT_WHATSAPP_MODE,BOT_WHATSAPP_MODE]);var resolveWhatsappTemplateReference=(reference,context)=>{if(reference==="whatsappConfig.mode"){return resolvedRenderTemplateReference(whatsappMode(context))}if(reference==="whatsappConfig.dmPolicy"){return resolvedRenderTemplateReference(whatsappDmPolicy(context))}const allowedIdsReference=reference.match(/^allowedIds[.]whatsapp[.](values|csv)$/);if(!allowedIdsReference?.[1])return void 0;const ids=allowedIds(context,"whatsapp");switch(allowedIdsReference[1]){case"values":return resolvedRenderTemplateReference(nonEmptyArray(ids));case"csv":return resolvedRenderTemplateReference(nonEmptyCsv(ids));default:return void 0}};function whatsappMode(context){const value=nonEmptyString(stateValue(context,"whatsappConfig.mode"));return value&&WHATSAPP_MODES.has(value)?value:DEFAULT_WHATSAPP_MODE}function whatsappDmPolicy(context){if(whatsappMode(context)!==BOT_WHATSAPP_MODE)return void 0;return allowedIds(context,"whatsapp").length>0?"allowlist":"pairing"}var BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS=[resolveTelegramTemplateReference,resolveDiscordTemplateReference,resolveWechatTemplateReference,resolveSlackTemplateReference,resolveWhatsappTemplateReference,resolveTeamsTemplateReference,resolveGooglechatTemplateReference];function createBuiltInRenderTemplateResolver(){return(reference,context)=>{for(const resolver of BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS){const resolved=resolver(reference,context);if(resolved)return resolved}return void 0}}var import_node_crypto=__toESM(require("node:crypto"));function hashCredential(value){const normalized=String(value??"").trim();if(!normalized)return null;return import_node_crypto.default.createHash("sha256").update(normalized).digest("hex")}function planCredentialBindings(manifest,context,inputs,environment=process.env){return manifest.credentials.map(credential=>{const sourceInput=inputs.find(input=>input.inputId===credential.sourceInput);const credentialAvailable=sourceInput?.credentialAvailable===true||context.credentialAvailability?.[credential.id]===true||context.credentialAvailability?.[`${manifest.id}.${credential.id}`]===true;const envKey=sourceInput?.sourceEnv??credential.providerEnvKey;const credentialHash=credentialAvailable?hashCredential(environment[envKey])??void 0:void 0;return{channelId:manifest.id,credentialId:credential.id,sourceInput:credential.sourceInput,providerName:resolveSandboxNameTemplate(credential.providerName,context.sandboxName),providerEnvKey:credential.providerEnvKey,placeholder:credential.placeholder,credentialAvailable,...credentialHash!==void 0?{credentialHash}:{}}})}function planHostForward(manifest,inputs,active,referenceResolver,environment=process.env){if(!active||!manifest.hostForward)return void 0;const context={inputs,env:environment,referenceResolver};if(!isTruthyRenderTemplate(manifest.hostForward.when,context))return void 0;const portValue=resolveRenderTemplatesInValue(manifest.hostForward.port,context);const port=normalizeForwardPort(manifest.id,portValue);return{channelId:manifest.id,port,label:manifest.hostForward.label}}function normalizeForwardPort(channelId,value){const port=typeof value==="number"?value:Number(String(value??"").trim());if(!Number.isInteger(port)||port<1||port>65535){throw new Error(`Channel manifest '${channelId}' declares invalid host forward port '${String(value)}'.`)}return port}var OPENSHELL_ENV_PLACEHOLDER_PREFIX="openshell:resolve:env:";var OPENSHELL_ALIAS_PLACEHOLDER_RE=/^[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-(.+)$/;function normalizeProviderPlaceholderForEnvKey(value,envKey){if(value.startsWith(OPENSHELL_ENV_PLACEHOLDER_PREFIX)){return placeholderSuffixMatchesEnvKey(value.slice(OPENSHELL_ENV_PLACEHOLDER_PREFIX.length),envKey)?`${OPENSHELL_ENV_PLACEHOLDER_PREFIX}${envKey}`:null}const aliasMatch=value.match(OPENSHELL_ALIAS_PLACEHOLDER_RE);if(!aliasMatch||!placeholderSuffixMatchesEnvKey(aliasMatch[1],envKey)){return null}return value.replace(/-OPENSHELL-RESOLVE-ENV-.+$/,`-OPENSHELL-RESOLVE-ENV-${envKey}`)}function placeholderSuffixMatchesEnvKey(suffix,envKey){if(suffix===envKey)return true;const revisionMatch=suffix.match(/^v[0-9]+_(.+)$/);return revisionMatch?.[1]===envKey}function hasFullPersistedCredentialBindingShape(binding){return typeof binding.channelId==="string"&&typeof binding.credentialId==="string"&&typeof binding.sourceInput==="string"&&typeof binding.providerName==="string"&&typeof binding.providerEnvKey==="string"&&typeof binding.placeholder==="string"&&typeof binding.credentialAvailable==="boolean"}function normalizeFullPersistedCredentialBindings(bindings){return bindings.map(binding=>({channelId:binding.channelId,credentialId:binding.credentialId,sourceInput:binding.sourceInput,providerName:binding.providerName,providerEnvKey:binding.providerEnvKey,placeholder:normalizeProviderPlaceholderForEnvKey(binding.placeholder,binding.providerEnvKey)??binding.placeholder,credentialAvailable:binding.credentialAvailable===true,...typeof binding.credentialHash==="string"?{credentialHash:binding.credentialHash}:{}}))}function normalizePersistedAgentCredentialPlaceholders(render,credentialBindings){const credentialEnvKeys=new Set(credentialBindings.map(binding=>binding.providerEnvKey).filter(Boolean));if(credentialEnvKeys.size===0)return[...render];return render.map(entry=>{if(entry.kind!=="env-lines")return entry;return{...entry,lines:entry.lines.map(line=>normalizeCredentialEnvLine(line,credentialEnvKeys))}})}function normalizeCredentialEnvLine(line,credentialEnvKeys){const index=line.indexOf("=");if(index<=0)return line;const envKey=line.slice(0,index).trim();if(!credentialEnvKeys.has(envKey))return line;const value=line.slice(index+1);const normalized=normalizeProviderPlaceholderForEnvKey(value,envKey);return normalized?`${envKey}=${normalized}`:line}function normalizePersistedSandboxMessagingPlanShape(plan,environment=process.env){const manifestRegistry=createBuiltInChannelManifestRegistry();const disabledChannels=plan.disabledChannels.filter(channelId=>typeof channelId==="string");const disabledSet=new Set(disabledChannels);const channels=plan.channels.map(channel=>normalizePersistedChannel(channel,disabledSet,manifestRegistry.get(channel.channelId),environment));const credentialBindings=normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment);const normalizedPlan={...plan,channels,disabledChannels,credentialBindings,networkPolicy:plan.networkPolicy&&Array.isArray(plan.networkPolicy.entries)?plan.networkPolicy:{presets:[],entries:[]},agentRender:normalizePersistedAgentCredentialPlaceholders(Array.isArray(plan.agentRender)?[...plan.agentRender]:[],credentialBindings),buildSteps:Array.isArray(plan.buildSteps)?[...plan.buildSteps]:[],...plan.runtimeSetup!==void 0?{runtimeSetup:normalizeRuntimeSetup(plan.runtimeSetup)}:{},stateUpdates:Array.isArray(plan.stateUpdates)?[...plan.stateUpdates]:[],healthChecks:Array.isArray(plan.healthChecks)?[...plan.healthChecks]:[]};return normalizedPlan}function normalizePersistedChannel(channel,disabledSet,manifest,environment){const disabled=channel.disabled??disabledSet.has(channel.channelId);const configured=channel.configured??true;const hasFullShape=hasFullChannelShape(channel);const inputs=hasFullShape?normalizeFullInputs(channel.channelId,channel.inputs??[]):normalizePersistedInputs(channel,manifest);const active=channel.active??(configured&&!disabled&&requiredInputsAvailable(manifest,inputs));const hostForward=manifest?planHostForward(manifest,inputs,active&&!disabled,createBuiltInRenderTemplateResolver(),environment):void 0;return{channelId:channel.channelId,displayName:channel.displayName??manifest?.displayName??channel.channelId,authMode:channel.authMode??manifest?.auth.mode??"none",active,selected:channel.selected??configured,configured,disabled,inputs,...hostForward?{hostForward}:{},hooks:Array.isArray(channel.hooks)?[...channel.hooks]:[]}}function normalizePersistedInputs(channel,manifest){const persistedById=new Map((channel.inputs??[]).filter(input=>typeof input.inputId==="string").map(input=>[input.inputId,input]));const fromManifest=(manifest?.inputs??[]).map(input=>inputReferenceFromManifest(channel.channelId,input,persistedById.get(input.id)));const manifestInputIds=new Set((manifest?.inputs??[]).map(input=>input.id));const unknownInputs=[...persistedById.values()].flatMap(input=>{if(!input.inputId||manifestInputIds.has(input.inputId))return[];return[normalizeUnknownInput(channel.channelId,input)]});return[...fromManifest,...unknownInputs]}function normalizeFullInputs(channelId,inputs){return inputs.filter(input=>typeof input.inputId==="string").map(input=>({channelId:typeof input.channelId==="string"?input.channelId:channelId,inputId:input.inputId,kind:input.kind==="secret"||input.kind==="config"?input.kind:"config",required:typeof input.required==="boolean"?input.required:false,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}))}function inputReferenceFromManifest(channelId,input,persisted){return{channelId,inputId:input.id,kind:input.kind,required:input.required,...input.envKey?{sourceEnv:input.envKey}:{},...input.kind==="config"&&input.statePath?{statePath:input.statePath}:{},...persisted?.credentialAvailable!==void 0?{credentialAvailable:persisted.credentialAvailable}:{},...persisted?.value!==void 0?{value:persisted.value}:{}}}function normalizeUnknownInput(channelId,input){const kind=input.kind==="secret"||input.kind==="config"?input.kind:"config";return{channelId,inputId:input.inputId,kind,required:input.required===true,...typeof input.sourceEnv==="string"?{sourceEnv:input.sourceEnv}:{},...typeof input.statePath==="string"?{statePath:input.statePath}:{},...input.credentialAvailable!==void 0?{credentialAvailable:input.credentialAvailable}:{},...input.value!==void 0?{value:input.value}:{}}}function requiredInputsAvailable(manifest,inputs){if(!manifest)return true;return manifest.inputs.every(manifestInput=>{if(!manifestInput.required)return true;const input=inputs.find(entry=>entry.inputId===manifestInput.id);if(!input)return false;if(input.kind==="secret")return input.credentialAvailable===true;if(input.value===void 0)return false;return typeof input.value==="string"?input.value.trim().length>0:true})}function normalizePersistedCredentialBindings(plan,channels,manifestRegistry,environment){const persisted=plan.credentialBindings??[];if(Array.isArray(plan.credentialBindings)&&plan.channels.every(hasFullChannelShape)&&persisted.every(hasFullPersistedCredentialBindingShape)){return normalizeFullPersistedCredentialBindings(persisted)}const manifests=channels.flatMap(channel=>{const manifest=manifestRegistry.get(channel.channelId);return manifest?[manifest]:[]});const planForBindings={...plan,channels,credentialBindings:[],networkPolicy:{presets:[],entries:[]},agentRender:[],buildSteps:[],runtimeSetup:{nodePreloads:[],envAliases:[],secretScans:[]},stateUpdates:[],healthChecks:[]};const generated=credentialBindingsFromManifests(planForBindings,manifests,new Map(channels.map(channel=>[channel.channelId,channel.inputs])),environment);return generated.map(binding=>overlayPersistedCredentialBinding(binding,persisted))}function credentialBindingsFromManifests(plan,manifests,inputRegistry,environment){const context=compilerContext(plan);return manifests.flatMap(manifest=>planCredentialBindings(manifest,context,inputRegistry.get(manifest.id)??[],environment).map(binding=>overlayPersistedCredentialBinding(binding,plan.credentialBindings)))}function overlayPersistedCredentialBinding(binding,persisted){const match=persisted.find(candidate=>credentialBindingMatches(binding,candidate));if(!match)return binding;return{...binding,credentialAvailable:typeof match.credentialAvailable==="boolean"?match.credentialAvailable:binding.credentialAvailable,...typeof match.credentialHash==="string"&&match.credentialHash.length>0?{credentialHash:match.credentialHash}:binding.credentialHash?{credentialHash:binding.credentialHash}:{}}}function credentialBindingMatches(binding,candidate){if(candidate.channelId&&candidate.channelId!==binding.channelId)return false;if(candidate.providerEnvKey&&candidate.providerEnvKey===binding.providerEnvKey)return true;if(candidate.credentialId&&candidate.credentialId===binding.credentialId)return true;if(candidate.sourceInput&&candidate.sourceInput===binding.sourceInput)return true;return false}function hasFullChannelShape(channel){return typeof channel.displayName==="string"&&typeof channel.authMode==="string"&&typeof channel.active==="boolean"&&typeof channel.selected==="boolean"&&typeof channel.configured==="boolean"&&typeof channel.disabled==="boolean"&&Array.isArray(channel.inputs)}function normalizeRuntimeSetup(setup){return{nodePreloads:Array.isArray(setup?.nodePreloads)?[...setup.nodePreloads]:[],envAliases:Array.isArray(setup?.envAliases)?[...setup.envAliases]:[],secretScans:Array.isArray(setup?.secretScans)?[...setup.secretScans]:[]}}function compilerContext(plan){return{sandboxName:plan.sandboxName,agent:plan.agent,workflow:plan.workflow,isInteractive:false,configuredChannels:plan.channels.map(channel=>channel.channelId),disabledChannels:plan.disabledChannels,credentialAvailability:credentialAvailabilityFromPlan(plan)}}function credentialAvailabilityFromPlan(plan){const availability={};for(const channel of plan.channels){for(const input of channel.inputs){if(input.kind!=="secret"||input.credentialAvailable!==true)continue;availability[`${channel.channelId}.${input.inputId}`]=true;if(input.sourceEnv)availability[input.sourceEnv]=true}}for(const credential of plan.credentialBindings){if(!credential.credentialAvailable)continue;availability[credential.credentialId]=true;availability[`${credential.channelId}.${credential.credentialId}`]=true;availability[`${credential.channelId}.${credential.sourceInput}`]=true;availability[credential.providerEnvKey]=true}return availability}function normalizeMessagingChannelId(channelId){return channelId.trim().toLowerCase()}function enabledPlanChannels(plan){const disabled=new Set((plan.disabledChannels??[]).map(normalizeMessagingChannelId).filter(Boolean));return plan.channels.filter(channel=>{const channelId=normalizeMessagingChannelId(channel.channelId);return channelId.length>0&&channel.active&&!channel.disabled&&!disabled.has(channelId)})}function selectActiveMessagingChannelIds(plan){const seen=new Set;const channels=[];for(const item of enabledPlanChannels(plan)){const channel=normalizeMessagingChannelId(item.channelId);if(!channel||seen.has(channel))continue;seen.add(channel);channels.push(channel)}return channels}function selectEnabledMessagingAgentRender(plan){const active=new Set(selectActiveMessagingChannelIds(plan));return plan.agentRender.filter(render=>render.agent===plan.agent&&active.has(normalizeMessagingChannelId(render.channelId)))}function selectEnabledPostAgentInstallBuildFiles(plan){const active=new Set(selectActiveMessagingChannelIds(plan));const channels=enabledPlanChannels(plan);return plan.buildSteps.filter(step=>{const channelId=normalizeMessagingChannelId(step.channelId);if(!active.has(channelId)||step.kind!=="build-file")return false;if(!step.hookId)return true;const matchingChannels=channels.filter(channel=>normalizeMessagingChannelId(channel.channelId)===channelId);if(matchingChannels.length!==1)return false;const matchedHook=matchingChannels[0]?.hooks?.find(hook=>hook.id===step.hookId);return matchedHook!==void 0&&matchedHook.phase==="post-agent-install"})}function parseSandboxMessagingPlan(value,options={}){if(!isObjectRecord(value)||value.schemaVersion!==1||typeof value.sandboxName!=="string"||typeof value.agent!=="string"||typeof value.workflow!=="string"||!Array.isArray(value.channels)||!Array.isArray(value.disabledChannels)||!isOptionalObjectArray(value,"credentialBindings")||Object.hasOwn(value,"networkPolicy")&&!isObjectRecord(value.networkPolicy)||!isOptionalObjectArray(value,"agentRender")||!isOptionalObjectArray(value,"buildSteps")||!isRuntimeSetup(value.runtimeSetup)||!isOptionalObjectArray(value,"stateUpdates")||!isOptionalObjectArray(value,"healthChecks")){return null}if(options.sandboxName&&value.sandboxName!==options.sandboxName)return null;if(options.agent&&value.agent!==options.agent)return null;const supported=Array.isArray(options.supportedChannelIds)?new Set(options.supportedChannelIds):null;const normalizedChannelIds=new Set;for(const channel of value.channels){if(!isObjectRecord(channel)||typeof channel.channelId!=="string")return null;const normalizedChannelId=normalizeMessagingChannelId(channel.channelId);if(!normalizedChannelId||normalizedChannelId!==channel.channelId||normalizedChannelIds.has(normalizedChannelId)){return null}if(Object.hasOwn(channel,"configured")&&typeof channel.configured!=="boolean"){return null}if(Object.hasOwn(channel,"active")&&typeof channel.active!=="boolean")return null;if(Object.hasOwn(channel,"disabled")&&typeof channel.disabled!=="boolean")return null;if(Object.hasOwn(channel,"inputs")&&!Array.isArray(channel.inputs))return null;if(Object.hasOwn(channel,"hostForward")&&!isHostForward(channel.hostForward))return null;if(Object.hasOwn(channel,"hooks")&&!Array.isArray(channel.hooks))return null;if(Array.isArray(channel.inputs)&&channel.inputs.some(input=>!isObjectRecord(input)||typeof input.inputId!=="string"||Object.hasOwn(input,"channelId")&&input.channelId!==normalizedChannelId)){return null}if(Array.isArray(channel.hooks)&&channel.hooks.some(hook=>!isObjectRecord(hook)||Object.hasOwn(hook,"channelId")&&hook.channelId!==normalizedChannelId)){return null}if(Object.hasOwn(channel,"hostForward")&&isObjectRecord(channel.hostForward)&&channel.hostForward.channelId!==normalizedChannelId){return null}if(supported&&!supported.has(channel.channelId))return null;normalizedChannelIds.add(normalizedChannelId)}if(!value.disabledChannels.every(isCanonicalMessagingChannelId))return null;const disabledChannelIds=new Set(value.disabledChannels);if(disabledChannelIds.size!==value.disabledChannels.length||[...disabledChannelIds].some(channelId=>!normalizedChannelIds.has(channelId))||value.channels.some(channel=>isObjectRecord(channel)&&channel.disabled===true!==disabledChannelIds.has(String(channel.channelId)))){return null}if(!hasCanonicalChannelReferences(value.credentialBindings)||!hasMatchingAgentRenderEntries(value.agentRender,value.agent)||!hasCanonicalChannelReferences(value.agentRender)||!hasCanonicalChannelReferences(value.buildSteps)||!hasCanonicalChannelReferences(value.stateUpdates)||!hasCanonicalChannelReferences(value.healthChecks)||!hasCanonicalNetworkPolicyReferences(value.networkPolicy)||!hasCanonicalRuntimeSetupReferences(value.runtimeSetup)){return null}return cloneSandboxMessagingPlan(normalizePersistedSandboxMessagingPlanShape(value,options.environment))}function hasMatchingAgentRenderEntries(value,agent){return!Array.isArray(value)||value.every(render=>isObjectRecord(render)&&render.agent===agent)}function cloneSandboxMessagingPlan(plan){return JSON.parse(JSON.stringify(plan))}function isOptionalObjectArray(value,key){if(!Object.hasOwn(value,key))return true;const entries=value[key];return Array.isArray(entries)&&entries.every(isObjectRecord)}function isHostForward(value){return isObjectRecord(value)&&typeof value.channelId==="string"&&typeof value.port==="number"&&Number.isInteger(value.port)&&value.port>=1&&value.port<=65535&&typeof value.label==="string"}function isRuntimeSetup(value){if(value===void 0)return true;return isObjectRecord(value)&&Array.isArray(value.nodePreloads)&&Array.isArray(value.envAliases)&&Array.isArray(value.secretScans)&&value.nodePreloads.every(isObjectRecord)&&value.envAliases.every(isObjectRecord)&&value.secretScans.every(isObjectRecord)}function isCanonicalMessagingChannelId(value){return typeof value==="string"&&value.length>0&&normalizeMessagingChannelId(value)===value}function hasCanonicalChannelReferences(value){return value===void 0||Array.isArray(value)&&value.every(entry=>isObjectRecord(entry)&&isCanonicalMessagingChannelId(entry.channelId))}function hasCanonicalNetworkPolicyReferences(value){if(!isObjectRecord(value)||!Object.hasOwn(value,"entries"))return true;return hasCanonicalChannelReferences(value.entries)}function hasCanonicalRuntimeSetupReferences(value){if(value===void 0)return true;if(!isObjectRecord(value))return false;return["nodePreloads","envAliases","secretScans"].every(field=>hasCanonicalChannelReferences(value[field]))}var import_node_buffer=require("node:buffer");var import_node_crypto2=require("node:crypto");var import_node_util=require("node:util");function listMessagingCredentialEnvAssignments(options={}){return selectManifests(options).flatMap(manifest=>{const credentialsByTemplate=new Map(manifest.credentials.map(credential=>[`{{credential.${credential.id}.placeholder}}`,credential]));return manifest.render.flatMap(render=>{if(options.agent&&render.agent!==options.agent)return[];if(render.kind!=="env-lines")return[];return render.lines.flatMap(line=>{const separator=line.indexOf("=");if(separator<=0)return[];const credential=credentialsByTemplate.get(line.slice(separator+1));if(!credential)return[];return[{channelId:manifest.id,agent:render.agent,sourceEnvKey:credential.providerEnvKey,targetEnvKey:line.slice(0,separator),placeholder:credential.placeholder}]})})})}function selectManifests(options){const manifests=options.manifests??BUILT_IN_CHANNEL_MANIFESTS;const agent=options.agent;const selected=agent?manifests.filter(manifest=>manifest.supportedAgents.includes(agent)):manifests;return[...selected]}function authorizeMessagingManagedStartupFields(entry,section){if(section==="agentRender")return authorizeTeamsOpenClawWebhookField(entry);if(!isPlainDataObject3(entry))return[];const contract=WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT;if(ownDataPropertyValue3(entry,"channelId")!==contract.channelId||ownDataPropertyValue3(entry,"hookId")!==contract.planHookId||ownDataPropertyValue3(entry,"handler")!==contract.handlerId||ownDataPropertyValue3(entry,"outputId")!==contract.outputId||ownDataPropertyValue3(entry,"kind")!==contract.kind||ownDataPropertyValue3(entry,"required")!==contract.required){return[]}return authorizeWechatAccountFilePlaceholders(ownDataPropertyValue3(entry,"value")).map(authorization=>({...authorization,path:["value",...authorization.path]}))}function isPlainDataObject3(value){if(value===null||typeof value!=="object"||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function ownDataPropertyValue3(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}var DCODE_UPSTREAM_PROVIDER_RE=/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;function isValidDcodeUpstreamProvider(value){return DCODE_UPSTREAM_PROVIDER_RE.test(value)}var MANAGED_STARTUP_PROFILE_SCHEMA_VERSION=1;var MANAGED_STARTUP_PROFILE_MAX_BYTES=64*1024;var MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES=Math.ceil(MANAGED_STARTUP_PROFILE_MAX_BYTES/3)*4;var MAX_IDENTIFIER_BYTES=256;var MAX_MODEL_BYTES=1024;var MAX_URL_BYTES=2048;var MAX_LIST_ITEMS=128;var MAX_JSON_NODES=4096;var MAX_JSON_DEPTH=32;var MAX_TUNING_INTEGER=1e9;var MIN_HERMES_CONTEXT_WINDOW=64e3;var SHA256_RE=/^[a-f0-9]{64}$/;var CONTROL_CHARACTER_RE=/[\u0000-\u001f\u007f-\u009f]/u;var BASE64URL_RE=/^[A-Za-z0-9_-]+$/;var RAW_CA_PEM_RE=/-----BEGIN (?:TRUSTED )?CERTIFICATE-----/iu;var RAW_CA_PEM_BASE64_RE=/^LS0tLS1CRUdJTi(?:BDRVJUSUZJQ0FURS0tLS0t|BUlVTVEVEIENFUlRJRklDQVRFLS0tLS0)/u;var RAW_CA_DER_BASE64_RE=/^MII[A-Za-z0-9+/=\r\n]{253,}$/u;var RAW_CA_DATA_URI_RE=/data:application\/(?:pkix-cert|x-x509-ca-cert);base64,MII[A-Za-z0-9+/=]{253,}/iu;var URL_CANDIDATE_RE=/[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'<>]+/gu;var UTF8_DECODER=new import_node_util.TextDecoder("utf-8",{fatal:true});var CREDENTIAL_SHAPED_NAME_PATTERN=/(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/iu;var CREDENTIAL_COMPOUND_NAME_PATTERN=/^(?:access|refresh|client|bearer|auth|api|private|signing|session|bot|app|resolved)(?:token|key|secret|password)$/iu;var CREDENTIAL_CAMEL_SUFFIX_PATTERN=/(?:apiKey|accessKey|secretKey|authToken|refreshToken|accessToken|clientSecret|privateKey|passcode|password|passwd|passphrase|bearerToken|botToken|appToken|sessionToken|signingKey|secretPublicKey|personalAccessToken|connectionString|webhookUrl)$/iu;var CREDENTIAL_CAMEL_BOUNDARY_PATTERN=/[a-z0-9](?:Token|Key|Secret|Password|Passphrase|Pat)$/u;var CREDENTIAL_ENV_NAME_PATTERN=/^(?:[A-Z0-9]+_)*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PASS|PASSPHRASE|CREDENTIAL)S?$/u;var CREDENTIAL_HEADER_NAME_PATTERN=/^(?:authorization|proxy-authorization|cookie|set-cookie|.+-(?:key|token|secret|password|passphrase|credential|auth)s?)$/iu;var PUBLIC_KEY_NAME_PATTERN=/^public[-_]?keys?$/iu;var PASS_CREDENTIAL_NAME_PATTERN=/(?:^|[-_])pass(?:wd)?$/iu;var NON_SECRET_KEY_METADATA_NAMES=new Set(["envKey","installCacheEnvKey","providerEnvKey","stateKey"]);var MESSAGING_CREDENTIAL_PLACEHOLDER_RE=/^(?:openshell:resolve:env:|[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-)(?:v[0-9]+_)?[A-Z][A-Z0-9_]*$/u;var MESSAGING_CREDENTIAL_ENV_ALIASES=new Set(listMessagingCredentialEnvAssignments().filter(({sourceEnvKey,targetEnvKey})=>sourceEnvKey!==targetEnvKey).map(({agent,sourceEnvKey,targetEnvKey})=>`${agent}\0${sourceEnvKey}\0${targetEnvKey}`));var JSON_ARRAY_INDEX_SEGMENT_RE=/^\[(?:0|[1-9][0-9]*)\]$/u;var SECRET_VALUE_PATTERNS=[/nvapi-[A-Za-z0-9_-]{10,}/u,/nvcf-[A-Za-z0-9_-]{10,}/u,/ghp_[A-Za-z0-9_-]{10,}/u,/github_pat_[A-Za-z0-9_]{30,}/u,/sk-(?:proj-|ant-)?[A-Za-z0-9_-]{10,}/u,/(?:xox[bpas]|xapp)-[A-Za-z0-9-]{10,}/u,/A(?:K|S)IA[A-Z0-9]{16}/u,/hf_[A-Za-z0-9]{10,}/u,/glpat-[A-Za-z0-9_-]{10,}/u,/gsk_[A-Za-z0-9]{10,}/u,/pypi-[A-Za-z0-9_-]{10,}/u,/tvly-[A-Za-z0-9_-]{10,}/u,/lsv2_(?:pt|sk)_[A-Za-z0-9]{10,}(?:_[A-Za-z0-9]+)*/u,/\bbot\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b\d{8,10}:[A-Za-z0-9_-]{35}\b/u,/\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/u,/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{10,}\b/u,/\bBearer\s+[A-Za-z0-9_.+/=-]{10,}/iu,/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----/u];var MANAGED_STARTUP_INFERENCE_APIS=["openai-completions","openai-responses","anthropic-messages"];var MANAGED_STARTUP_REASONING_EFFORTS=["default","low","medium","high"];var MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES=["disabled","thread-opt-in"];var MANAGED_STARTUP_HERMES_TOOL_GATEWAYS=["nous-web","nous-image","nous-audio","nous-browser","nous-code"];var MANAGED_STARTUP_AGENTS=["openclaw","hermes","langchain-deepagents-code","pi"];var MANAGED_STARTUP_MESSAGING_AGENTS=["openclaw","hermes"];function freezeAgentCapabilities(capabilities){return Object.freeze({...capabilities,inferenceApis:Object.freeze([...capabilities.inferenceApis]),dashboardModes:Object.freeze([...capabilities.dashboardModes]),inputModalities:Object.freeze([...capabilities.inputModalities]),webSearchProviders:Object.freeze([...capabilities.webSearchProviders]),toolGateways:Object.freeze([...capabilities.toolGateways]),tuningFields:Object.freeze([...capabilities.tuningFields])})}var PROFILE_CAPABILITIES={openclaw:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["loopback","remote"],inputModalities:["text","image"],webSearchProviders:["brave","tavily"],toolGateways:[],tuningFields:["contextWindow","maxTokens","reasoning","reasoningEffort"],supportsMessaging:true,supportsInferenceCompatibility:true,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:true,supportsAgentTimeout:true,supportsHeartbeat:true,supportsExtraAgents:true,supportsDeviceAuth:true,observability:"openclaw-otel",supportsMinimalBootstrap:true},hermes:{inferenceApis:[...MANAGED_STARTUP_INFERENCE_APIS],dashboardModes:["disabled","loopback-forwarded"],inputModalities:[],webSearchProviders:["tavily"],toolGateways:[...MANAGED_STARTUP_HERMES_TOOL_GATEWAYS],tuningFields:["contextWindow"],supportsMessaging:true,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"none",supportsMinimalBootstrap:false},"langchain-deepagents-code":{inferenceApis:["openai-completions"],dashboardModes:["disabled"],inputModalities:[],webSearchProviders:[],toolGateways:[],tuningFields:["reasoningEffort"],supportsMessaging:false,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:true,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"dcode-marker",supportsMinimalBootstrap:false},pi:{inferenceApis:["openai-completions"],dashboardModes:["disabled"],inputModalities:[],webSearchProviders:[],toolGateways:[],tuningFields:["contextWindow","maxTokens","reasoning"],supportsMessaging:false,supportsInferenceCompatibility:false,supportsUpstreamEndpoint:false,supportsHostProxyIntent:true,supportsPrimaryModelRef:false,supportsAgentTimeout:false,supportsHeartbeat:false,supportsExtraAgents:false,supportsDeviceAuth:false,observability:"none",supportsMinimalBootstrap:false}};for(const agent of MANAGED_STARTUP_AGENTS){Object.defineProperty(PROFILE_CAPABILITIES,agent,{configurable:false,enumerable:true,value:freezeAgentCapabilities(PROFILE_CAPABILITIES[agent]),writable:false})}var MANAGED_STARTUP_PROFILE_CAPABILITIES=Object.freeze(PROFILE_CAPABILITIES);function affordance(input,profilePath,source="docker-arg",representation="value"){return{input,profilePath,source,representation}}var HOST_PROXY_AFFORDANCES=[affordance("HTTP_PROXY","proxy.hostHttpUrl","runtime-env"),affordance("http_proxy","proxy.hostHttpUrl","runtime-env","derived"),affordance("HTTPS_PROXY","proxy.hostHttpsUrl","runtime-env"),affordance("https_proxy","proxy.hostHttpsUrl","runtime-env","derived"),affordance("NO_PROXY","proxy.hostNoProxy","runtime-env"),affordance("no_proxy","proxy.hostNoProxy","runtime-env","derived")];var MANAGED_STARTUP_PROFILE_AFFORDANCE_INVENTORY={openclaw:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_PRIMARY_MODEL_REF","inference.primaryModelRef"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_INFERENCE_COMPAT_B64","inference.compatibility"),affordance("NEMOCLAW_INFERENCE_INPUTS","inference.inputModalities"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_MAX_TOKENS","tuning.maxTokens"),affordance("NEMOCLAW_REASONING","tuning.reasoning"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_AGENT_TIMEOUT","agentConfig.agentTimeoutSeconds"),affordance("NEMOCLAW_AGENT_HEARTBEAT_EVERY","agentConfig.heartbeatEvery"),affordance("NEMOCLAW_EXTRA_AGENTS_JSON_B64","agentConfig.extraAgents"),affordance("NEMOCLAW_DISABLE_DEVICE_AUTH","agentConfig.deviceAuth.disabled"),affordance("NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE","agentConfig.deviceAuth.optOutSource"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_OPENCLAW_OTEL","agentConfig.otel.enabled"),affordance("NEMOCLAW_OPENCLAW_OTEL_ENDPOINT","agentConfig.otel.endpointUrl"),affordance("NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME","agentConfig.otel.serviceName"),affordance("NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE","agentConfig.otel.sampleRate"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_BIND","dashboard.bindAddress"),affordance("NEMOCLAW_WSL_DASHBOARD_EXPOSURE","dashboard.wslExposure"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.port","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("NEMOCLAW_MINIMAL_BOOTSTRAP","agentConfig.minimalBootstrap","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],hermes:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER","tools.enabledGateways","docker-arg","derived"),affordance("NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64","tools.enabledGateways"),affordance("NEMOCLAW_WEB_SEARCH_ENABLED","agentConfig.webSearch.enabled"),affordance("NEMOCLAW_WEB_SEARCH_PROVIDER","agentConfig.webSearch.provider"),affordance("NEMOCLAW_MESSAGING_PLAN_B64","messaging.plan"),affordance("CHAT_UI_URL","dashboard.url"),affordance("NEMOCLAW_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD","dashboard.mode","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_PORT","dashboard.publicPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT","dashboard.internalPort","runtime-env"),affordance("NEMOCLAW_HERMES_DASHBOARD_TUI","dashboard.tuiEnabled","runtime-env"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost","runtime-env"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],"langchain-deepagents-code":[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_UPSTREAM_ENDPOINT_URL","inference.upstreamEndpointUrl"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_REASONING_EFFORT","tuning.reasoningEffort"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_DCODE_AUTO_APPROVAL","agentConfig.autoApprovalMode"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_OBSERVABILITY","agentConfig.observabilityEnabled","runtime-env"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES],pi:[affordance("NEMOCLAW_MODEL","inference.model"),affordance("NEMOCLAW_INFERENCE_PROVIDER_ID","inference.routeProvider"),affordance("NEMOCLAW_UPSTREAM_PROVIDER","inference.upstreamProvider"),affordance("NEMOCLAW_INFERENCE_BASE_URL","inference.routedBaseUrl"),affordance("NEMOCLAW_INFERENCE_API","inference.api"),affordance("NEMOCLAW_CONTEXT_WINDOW","tuning.contextWindow"),affordance("NEMOCLAW_MAX_TOKENS","tuning.maxTokens"),affordance("NEMOCLAW_REASONING","tuning.reasoning"),affordance("NEMOCLAW_TOOL_DISCLOSURE","tools.disclosure"),affordance("NEMOCLAW_PROXY_HOST","proxy.managedHost"),affordance("NEMOCLAW_PROXY_PORT","proxy.managedPort"),affordance("NEMOCLAW_CORPORATE_CA_B64","corporateCa.bundleSha256","host-material","digest-handoff"),...HOST_PROXY_AFFORDANCES]};function deferredRuntimeInput(input,owner,reason,admission="managed-launch-forwarded"){return Object.freeze({input,owner,admission,reason})}var MANAGED_STARTUP_PROFILE_DEFERRED_RUNTIME_INPUTS=Object.freeze({openclaw:Object.freeze([deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","application-environment","operator scheduler tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_SHADOW_DIAGNOSTICS","application-environment","operator shadow-diagnostics tuning is applied by the application environment transaction"),deferredRuntimeInput("NEMOCLAW_MCP_TOOLS_LIST_TIMEOUT_MS","application-environment","operator MCP discovery timeout tuning is applied by the application environment transaction"),deferredRuntimeInput("OPENCLAW_HOME","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_STATE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("OPENCLAW_WORKSPACE_DIR","fixed-image-contract","the managed image and agent definition own this fixed runtime layout path"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),hermes:Object.freeze([deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),"langchain-deepagents-code":Object.freeze([deferredRuntimeInput("NEMOCLAW_SANDBOX_NAME","engine-identity","the lifecycle engine owns instance identity outside reusable startup intent"),deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")]),pi:Object.freeze([deferredRuntimeInput("NEMOCLAW_EXTRA_PLACEHOLDER_KEYS","credential-plumbing","credential provider construction owns key metadata outside the secret-free profile")])});function runtimeCleanupObligation(input,emittedFor,supportedFor,reason){return Object.freeze({input,emittedFor:Object.freeze([...emittedFor]),supportedFor:Object.freeze([...supportedFor]),owner:"application-environment",reason})}var MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS=Object.freeze([runtimeCleanupObligation("NEMOCLAW_DASHBOARD_BIND",["hermes"],["openclaw"],"generic managed-dashboard construction currently emits the OpenClaw-only bind control for Hermes"),runtimeCleanupObligation("NEMOCLAW_MINIMAL_BOOTSTRAP",["hermes","langchain-deepagents-code"],["openclaw"],"generic host-proxy construction currently emits the OpenClaw-only bootstrap control for other agents")]);var ManagedStartupProfileError=class extends Error{constructor(message){super(`Invalid managed startup profile: ${message}`);this.name="ManagedStartupProfileError"}};var PROFILE_KEYS=new Set(["schemaVersion","agent","agentConfig","inference","proxy","dashboard","tools","messaging","tuning","corporateCa"]);var INFERENCE_KEYS=new Set(["routeProvider","upstreamProvider","model","routedBaseUrl","upstreamEndpointUrl","api","primaryModelRef","compatibility","inputModalities"]);var PROXY_KEYS=new Set(["managedHost","managedPort","hostHttpUrl","hostHttpsUrl","hostNoProxy"]);var OPENCLAW_DASHBOARD_KEYS=new Set(["agent","mode","url","port","bindAddress","wslExposure"]);var HERMES_DASHBOARD_KEYS=new Set(["agent","mode","url","publicPort","internalPort","tuiEnabled"]);var DCODE_DASHBOARD_KEYS=new Set(["agent","mode"]);var TOOLS_KEYS=new Set(["disclosure","enabledGateways"]);var MESSAGING_KEYS=new Set(["plan"]);var TUNING_FIELD_ORDER=["contextWindow","maxTokens","reasoning","reasoningEffort"];var TUNING_KEYS=new Set(TUNING_FIELD_ORDER);var CORPORATE_CA_KEYS=new Set(["bundleSha256"]);var OPENCLAW_CONFIG_KEYS=new Set(["agent","webSearch","otel","agentTimeoutSeconds","heartbeatEvery","extraAgents","deviceAuth","minimalBootstrap"]);var HERMES_CONFIG_KEYS=new Set(["agent","webSearch"]);var DCODE_CONFIG_KEYS=new Set(["agent","autoApprovalMode","observabilityEnabled"]);var PI_CONFIG_KEYS=new Set(["agent"]);var PI_DASHBOARD_KEYS=new Set(["agent","mode"]);var WEB_SEARCH_KEYS=new Set(["enabled","provider"]);var OTEL_KEYS=new Set(["enabled","endpointUrl","serviceName","sampleRate"]);var DEVICE_AUTH_KEYS=new Set(["disabled","optOutSource"]);var EXTRA_AGENTS_KEYS=new Set(["agents","defaults","main"]);var MANAGED_STARTUP_AGENT_SET=new Set(MANAGED_STARTUP_AGENTS);var DCODE_AUTO_APPROVAL_MODE_SET=new Set(MANAGED_STARTUP_DCODE_AUTO_APPROVAL_MODES);var REASONING_EFFORT_SET=new Set(MANAGED_STARTUP_REASONING_EFFORTS);var HERMES_INTERNAL_API_PORT=18642;var HERMES_API_PORT_RANGE_START=8642;var HERMES_API_PORT_RANGE_END=8652;function isHermesApiPort(port){return port>=HERMES_API_PORT_RANGE_START&&port<=HERMES_API_PORT_RANGE_END}function isHermesReservedApiPort(port){return port===HERMES_INTERNAL_API_PORT||isHermesApiPort(port)}var HERMES_RESERVED_API_PORT_LABEL=`${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END} or ${HERMES_INTERNAL_API_PORT}`;function isPlainObject(value){if(typeof value!=="object"||value===null||Array.isArray(value))return false;const prototype=Object.getPrototypeOf(value);return prototype===Object.prototype||prototype===null}function isCredentialShapedName(name){if(PUBLIC_KEY_NAME_PATTERN.test(name)||NON_SECRET_KEY_METADATA_NAMES.has(name))return false;return CREDENTIAL_SHAPED_NAME_PATTERN.test(name)||CREDENTIAL_COMPOUND_NAME_PATTERN.test(name)||CREDENTIAL_CAMEL_SUFFIX_PATTERN.test(name)||CREDENTIAL_CAMEL_BOUNDARY_PATTERN.test(name)||CREDENTIAL_ENV_NAME_PATTERN.test(name)||CREDENTIAL_HEADER_NAME_PATTERN.test(name)||PASS_CREDENTIAL_NAME_PATTERN.test(name)}function valueLooksLikeSecret(value){for(let index=0;index=5&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="agentRender"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")&&path5[4]==="value";const isAuthorizedBuildStepPlaceholder=allowedBuildStepPlaceholders.has(buildStepPlaceholderKey(path5,value));return isCredentialBindingPlaceholder||isAgentRenderValuePlaceholder||isAuthorizedBuildStepPlaceholder}function requiresMessagingSchemaFieldAuthorization(path5){const fieldName=path5[path5.length-1];return fieldName==="webhook"}function messagingAuthorizedFieldKey(path5){return JSON.stringify(path5)}function buildStepPlaceholderKey(path5,value){return JSON.stringify([path5,value])}function messagingCredentialPlaceholderEnvKey(value){if(!MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value))return null;const marker=value.startsWith("openshell:resolve:env:")?"openshell:resolve:env:":"-OPENSHELL-RESOLVE-ENV-";const key=value.slice(value.indexOf(marker)+marker.length);return key.replace(/^v[0-9]+_/u,"")}function containsMessagingCredentialPlaceholder(value){return value.includes("openshell:resolve:env:")||value.includes("-OPENSHELL-RESOLVE-ENV-")}function isMessagingCredentialPlaceholderAssignment(selectedAgent,path5,value){if(path5.length!==6||path5[0]!=="messaging"||path5[1]!=="plan"||path5[2]!=="agentRender"||!JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")||path5[4]!=="lines"||!JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[5]??"")){return false}const separator=value.indexOf("=");if(separator<=0||value.indexOf("=",separator+1)!==-1)return false;const envKey=value.slice(0,separator);const placeholder=value.slice(separator+1);const placeholderEnvKey=messagingCredentialPlaceholderEnvKey(placeholder);return CREDENTIAL_ENV_NAME_PATTERN.test(envKey)&&placeholderEnvKey!==null&&(envKey===placeholderEnvKey||typeof selectedAgent==="string"&&MESSAGING_CREDENTIAL_ENV_ALIASES.has(`${selectedAgent}\0${placeholderEnvKey}\0${envKey}`))}function isMessagingRuntimeEnvAliasPath(path5){return path5.length===5&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="runtimeSetup"&&path5[3]==="envAliases"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[4]??"")}function ownDataPropertyValue4(value,key){const descriptor=Object.getOwnPropertyDescriptor(value,key);return descriptor&&"value"in descriptor?descriptor.value:void 0}function isStockTeamsOpenClawWebhook(root,path5,value){if(path5.length!==6||path5[0]!=="messaging"||path5[1]!=="plan"||path5[2]!=="agentRender"||!JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")||path5[4]!=="value"||path5[5]!=="webhook"||!isPlainObject(root)||ownDataPropertyValue4(root,"agent")!=="openclaw"){return false}const messaging=ownDataPropertyValue4(root,"messaging");if(!isPlainObject(messaging))return false;const plan=ownDataPropertyValue4(messaging,"plan");if(!isPlainObject(plan)||ownDataPropertyValue4(plan,"agent")!=="openclaw")return false;const agentRender=ownDataPropertyValue4(plan,"agentRender");if(!Array.isArray(agentRender))return false;const entryIndex=path5[3].slice(1,-1);const entryDescriptor=Object.getOwnPropertyDescriptor(agentRender,entryIndex);const entry=entryDescriptor&&"value"in entryDescriptor?entryDescriptor.value:void 0;if(!isPlainObject(entry))return false;const renderValue=ownDataPropertyValue4(entry,"value");if(!isPlainObject(renderValue)||ownDataPropertyValue4(renderValue,"webhook")!==value){return false}if(ownDataPropertyValue4(entry,"channelId")!=="teams"||ownDataPropertyValue4(entry,"renderId")!=="teams-openclaw-channel"||ownDataPropertyValue4(entry,"hookId")!=="teams-openclaw-channel"||ownDataPropertyValue4(entry,"handler")!=="common.staticOutputs"||ownDataPropertyValue4(entry,"kind")!=="json-fragment"||ownDataPropertyValue4(entry,"agent")!=="openclaw"||ownDataPropertyValue4(entry,"target")!=="openclaw.json"||ownDataPropertyValue4(entry,"path")!=="channels.msteams"||!isPlainObject(value)){return false}const keys=Object.getOwnPropertyNames(value);if(keys.length!==2||!keys.includes("port")||!keys.includes("path"))return false;const port=ownDataPropertyValue4(value,"port");return typeof port==="number"&&Number.isInteger(port)&&port>=1&&port<=65535&&ownDataPropertyValue4(value,"path")==="/api/messages"}function isCanonicalMessagingRuntimeEnvAlias(path5,value){if(!isMessagingRuntimeEnvAliasPath(path5))return false;const envKey=ownDataPropertyValue4(value,"envKey");const match=ownDataPropertyValue4(value,"match");const placeholder=ownDataPropertyValue4(value,"value");return typeof envKey==="string"&&CREDENTIAL_ENV_NAME_PATTERN.test(envKey)&&match===`^openshell:resolve:env:(v[0-9]+_)?${envKey}$`&&typeof placeholder==="string"&&messagingCredentialPlaceholderEnvKey(placeholder)===envKey}function isAllowedMessagingRuntimeAliasStringPath(path5,allowedAliasIndexes){return path5.length===6&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="runtimeSetup"&&path5[3]==="envAliases"&&allowedAliasIndexes.has(path5[4]??"")&&(path5[5]==="match"||path5[5]==="value")}function isMessagingPackagePin(path5,value){return path5.length===6&&path5[0]==="messaging"&&path5[1]==="plan"&&path5[2]==="buildSteps"&&JSON_ARRAY_INDEX_SEGMENT_RE.test(path5[3]??"")&&path5[4]==="value"&&path5[5]==="pin"&&typeof value==="boolean"}function containsUrlWithCredentialMaterial(value){const candidates=value.match(URL_CANDIDATE_RE)??[];for(let index=0;index{if(isCredentialShapedName(key))credentialQuery=true});const fragment=url.hash.startsWith("#")?url.hash.slice(1):url.hash;const queryStart=fragment.indexOf("?");const fragmentParameters=new URLSearchParams(queryStart>=0?fragment.slice(queryStart+1):fragment);let credentialFragment=false;fragmentParameters.forEach((_fragmentValue,key)=>{if(isCredentialShapedName(key))credentialFragment=true});if(url.username||url.password||credentialQuery||credentialFragment)return true}catch{}}return false}function invalid(reason){throw new ManagedStartupProfileError(reason)}function payloadPath(path5){return path5.reduce((result,segment)=>segment.startsWith("[")?`${result}${segment}`:`${result}${result?".":""}${segment}`,"")}function mapArrayByIndex(values,mapper){const mapped=[];for(let index=0;index0&&values[insertion-1]>selected){Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:values[insertion-1],writable:true});insertion-=1}Object.defineProperty(values,String(insertion),{configurable:true,enumerable:true,value:selected,writable:true})}return values}function requireRecord(value,where){if(!isPlainObject(value))invalid(`${where} must be an object`);return value}function rejectUnknownKeys(value,allowed,where){const keys=Object.keys(value);for(let index=0;indexmaxBytes||CONTROL_CHARACTER_RE.test(value)){invalid(`${where} must be a bounded, non-empty string without control characters`)}return value}function requireStringEnum(value,allowed,where){const normalized=requireBoundedString(value,where);if(!allowed.has(normalized))invalid(`${where} is not supported`);return normalized}function requireNullablePositiveInteger(value,where){if(value===null)return null;if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>MAX_TUNING_INTEGER){invalid(`${where} must be null or a bounded positive integer`)}return value}function requirePositiveInteger(value,where,maximum=MAX_TUNING_INTEGER){if(typeof value!=="number"||!Number.isSafeInteger(value)||value<1||value>maximum){invalid(`${where} must be a bounded positive integer`)}return value}function requirePort(value,where,minimum=1){if(typeof value!=="number"||!Number.isInteger(value)||value<1||value>65535){invalid(`${where} must be a valid TCP port`)}if(valueMAX_LIST_ITEMS){invalid(`${where} must be a bounded string list`)}const items=mapArrayByIndex(value,item=>requireBoundedString(item,`${where} item`));const unique2=new Set;for(let index=0;index{if(depth>MAX_JSON_DEPTH)invalid(`${where} exceeds the JSON depth limit`);if(current===null||typeof current==="string"||typeof current==="boolean"){return current}if(typeof current==="number"){if(!Number.isFinite(current))invalid(`${where} contains a non-finite number`);return current}if(Array.isArray(current)){return mapArrayByIndex(current,item=>clone(item,depth+1))}if(!isPlainObject(current))invalid(`${where} contains a non-JSON value`);const result=options.nullPrototypeObjects?Object.create(null):{};const keys=Object.getOwnPropertyNames(current);for(let index=0;indexMAX_IDENTIFIER_BYTES||CONTROL_CHARACTER_RE.test(key)){invalid(`${where} contains an invalid object key`)}const descriptor=Object.getOwnPropertyDescriptor(current,key);if(!descriptor||!("value"in descriptor)){invalid(`${where} contains a non-JSON value`)}Object.defineProperty(result,key,{configurable:true,enumerable:true,value:clone(descriptor.value,depth+1),writable:true})}return result};return clone(value,0)}function requireJsonObjectOrNull(value,where){if(value===null)return null;if(!isPlainObject(value))invalid(`${where} must be null or a plain JSON object`);return cloneJsonValue(value,where,{nullPrototypeObjects:true})}function requireJsonObject(value,where){const object=requireJsonObjectOrNull(value,where);if(object===null)invalid(`${where} must be a plain JSON object`);return object}function requireHttpUrl(value,where){const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) URL`)}if(parsed.protocol!=="http:"&&parsed.protocol!=="https:"||parsed.username||parsed.password||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) URL without query or fragment data`)}const pathname=parsed.pathname.replace(/\/+$/u,"");return pathname===""?parsed.origin:`${parsed.origin}${pathname}`}function requireProxyUrl(value,allowedSchemes,where){if(value===null)return null;const raw=requireBoundedString(value,where,MAX_URL_BYTES);let parsed;try{parsed=new URL(raw)}catch{invalid(`${where} must be a valid HTTP(S) proxy URL`)}if(!allowedSchemes.has(parsed.protocol)||parsed.username||parsed.password||parsed.pathname!=="/"||parsed.search||parsed.hash){invalid(`${where} must be a credential-free HTTP(S) proxy origin`)}return parsed.origin}function requireManagedProxyHost(value,where){const host=requireBoundedString(value,where);if(!/^[A-Za-z0-9._-]+$/u.test(host)){invalid(`${where} must be a hostname or IPv4 address without a scheme or separators`)}return host}function isLoopbackUrl(value){const hostname=new URL(value).hostname.toLowerCase();return hostname==="localhost"||hostname==="127.0.0.1"||hostname==="::1"||hostname==="[::1]"}function configuredDashboardPort(value){const explicit=new URL(value).port;return explicit===""?18789:Number(explicit)}function requireSampleRate(value,where){if(typeof value!=="number"||!Number.isFinite(value)||value<0||value>1){invalid(`${where} must be a number between 0 and 1`)}return value}function assertPayloadStructureAndCredentialShapes(root){const pending=[{value:root,depth:0,path:[]}];const allowedRuntimeAliasIndexes=new Set;const allowedMessagingCredentialFields=new Set;const allowedBuildStepPlaceholders=new Set;const selectedAgent=isPlainObject(root)?ownDataPropertyValue4(root,"agent"):void 0;let discoveredNodes=1;let observedBytes=0;const observeText=value=>{observedBytes+=import_node_buffer.Buffer.byteLength(value,"utf8");if(observedBytes>MANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}};const reserveNode=depth=>{discoveredNodes+=1;if(discoveredNodes>MAX_JSON_NODES||depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}observedBytes+=1};while(pending.length>0){const current=pending.pop();if(!current)break;if(current.depth>MAX_JSON_DEPTH){invalid("payload structure exceeds the complexity limit")}if(typeof current.value==="string"){observeText(current.value);if(!isAllowedMessagingRuntimeAliasStringPath(current.path,allowedRuntimeAliasIndexes)&&!isMessagingCredentialPlaceholder(current.path,current.value,allowedBuildStepPlaceholders,allowedMessagingCredentialFields)&&!isMessagingCredentialPlaceholderAssignment(selectedAgent,current.path,current.value)&&(valueLooksLikeSecret(current.value)||containsMessagingCredentialPlaceholder(current.value))){invalid(`payload field ${payloadPath(current.path)} contains credential-shaped string data`)}if(RAW_CA_PEM_RE.test(current.value)||RAW_CA_PEM_BASE64_RE.test(current.value)||RAW_CA_DER_BASE64_RE.test(current.value)||RAW_CA_DATA_URI_RE.test(current.value)){invalid(`payload field ${payloadPath(current.path)} contains raw certificate data; provide only the CA SHA-256 digest`)}if(containsUrlWithCredentialMaterial(current.value)){invalid(`payload field ${payloadPath(current.path)} contains a URL with embedded credentials`)}continue}if(Array.isArray(current.value)){if(Object.getPrototypeOf(current.value)!==Array.prototype){invalid("payload arrays must use the standard JSON prototype")}if("toJSON"in current.value){invalid("payload must not define a custom JSON serializer")}if(Object.getOwnPropertySymbols(current.value).length>0||Object.getOwnPropertyNames(current.value).length!==current.value.length+1){invalid("payload arrays must contain only indexed JSON values")}for(let index=0;index0||discoveredNodes+keys.length>MAX_JSON_NODES){invalid("payload structure exceeds the complexity limit")}for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}}function validateWebSearch(value,agent){const webSearch=requireRecord(value,"agentConfig.webSearch");rejectUnknownKeys(webSearch,WEB_SEARCH_KEYS,"agentConfig.webSearch");const provider=requireStringEnum(webSearch.provider,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].webSearchProviders),"agentConfig.webSearch.provider");return{enabled:requireBoolean(webSearch.enabled,"agentConfig.webSearch.enabled"),provider}}function validateOpenClawOtel(value){const otel=requireRecord(value,"agentConfig.otel");rejectUnknownKeys(otel,OTEL_KEYS,"agentConfig.otel");return{enabled:requireBoolean(otel.enabled,"agentConfig.otel.enabled"),endpointUrl:requireHttpUrl(otel.endpointUrl,"agentConfig.otel.endpointUrl"),serviceName:requireBoundedString(otel.serviceName,"agentConfig.otel.serviceName",MAX_IDENTIFIER_BYTES),sampleRate:requireSampleRate(otel.sampleRate,"agentConfig.otel.sampleRate")}}function validateExtraAgents(value){const extraAgents=requireRecord(value,"agentConfig.extraAgents");rejectUnknownKeys(extraAgents,EXTRA_AGENTS_KEYS,"agentConfig.extraAgents");if(!Array.isArray(extraAgents.agents)||extraAgents.agents.length>MAX_LIST_ITEMS){invalid("agentConfig.extraAgents.agents must be a bounded JSON object list")}return{agents:mapArrayByIndex(extraAgents.agents,(agent,index)=>requireJsonObject(agent,`agentConfig.extraAgents.agents[${String(index)}]`)),defaults:requireJsonObject(extraAgents.defaults,"agentConfig.extraAgents.defaults"),main:requireJsonObject(extraAgents.main,"agentConfig.extraAgents.main")}}function validateDeviceAuth(value){const deviceAuth=requireRecord(value,"agentConfig.deviceAuth");rejectUnknownKeys(deviceAuth,DEVICE_AUTH_KEYS,"agentConfig.deviceAuth");return{disabled:requireBoolean(deviceAuth.disabled,"agentConfig.deviceAuth.disabled"),optOutSource:requireStringEnum(deviceAuth.optOutSource,new Set(["operator","managed-onboard"]),"agentConfig.deviceAuth.optOutSource")}}function validateAgentConfig(value,expectedAgent){const config=requireRecord(value,"agentConfig");const agent=requireStringEnum(config.agent,MANAGED_STARTUP_AGENT_SET,"agentConfig.agent");if(agent!==expectedAgent)invalid("agentConfig.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(config,OPENCLAW_CONFIG_KEYS,"agentConfig");const heartbeatEvery=config.heartbeatEvery===null?null:requireBoundedString(config.heartbeatEvery,"agentConfig.heartbeatEvery",MAX_IDENTIFIER_BYTES);if(heartbeatEvery!==null&&!/^\d+(?:s|m|h)$/u.test(heartbeatEvery)){invalid("agentConfig.heartbeatEvery must be null or a duration ending in s, m, or h")}return{agent,webSearch:validateWebSearch(config.webSearch,agent),otel:validateOpenClawOtel(config.otel),agentTimeoutSeconds:requirePositiveInteger(config.agentTimeoutSeconds,"agentConfig.agentTimeoutSeconds"),heartbeatEvery,extraAgents:validateExtraAgents(config.extraAgents),deviceAuth:validateDeviceAuth(config.deviceAuth),minimalBootstrap:requireBoolean(config.minimalBootstrap,"agentConfig.minimalBootstrap")}}if(agent==="hermes"){rejectUnknownKeys(config,HERMES_CONFIG_KEYS,"agentConfig");return{agent,webSearch:validateWebSearch(config.webSearch,agent)}}if(agent==="pi"){rejectUnknownKeys(config,PI_CONFIG_KEYS,"agentConfig");return{agent}}rejectUnknownKeys(config,DCODE_CONFIG_KEYS,"agentConfig");return{agent,autoApprovalMode:requireStringEnum(config.autoApprovalMode,DCODE_AUTO_APPROVAL_MODE_SET,"agentConfig.autoApprovalMode"),observabilityEnabled:requireBoolean(config.observabilityEnabled,"agentConfig.observabilityEnabled")}}function validateDashboard(value,expectedAgent){const dashboard=requireRecord(value,"dashboard");const agent=requireStringEnum(dashboard.agent,MANAGED_STARTUP_AGENT_SET,"dashboard.agent");if(agent!==expectedAgent)invalid("dashboard.agent must match agent");if(agent==="openclaw"){rejectUnknownKeys(dashboard,OPENCLAW_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");const bindAddress=requireStringEnum(dashboard.bindAddress,new Set(["127.0.0.1","0.0.0.0"]),"dashboard.bindAddress");const wslExposure=requireBoolean(dashboard.wslExposure,"dashboard.wslExposure");const hasRemoteExposure=!isLoopbackUrl(url)||bindAddress==="0.0.0.0"||wslExposure;if(mode==="remote"!==hasRemoteExposure){invalid("OpenClaw dashboard.mode must reflect its URL, bind address, and WSL exposure")}const port=requirePort(dashboard.port,"dashboard.port",1024);if(isHermesApiPort(port))invalid(`OpenClaw dashboard.port must not use a reserved Hermes API port (${HERMES_API_PORT_RANGE_START}-${HERMES_API_PORT_RANGE_END})`);if(configuredDashboardPort(url)!==port){invalid("OpenClaw dashboard.port must match dashboard.url")}return{agent,mode,url,port,bindAddress,wslExposure}}if(agent==="hermes"){rejectUnknownKeys(dashboard,HERMES_DASHBOARD_KEYS,"dashboard");const mode=requireStringEnum(dashboard.mode,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].dashboardModes),"dashboard.mode");const url=requireHttpUrl(dashboard.url,"dashboard.url");if(!isLoopbackUrl(url)){invalid("Hermes dashboard.url must remain loopback; OpenShell owns the host forward")}if(mode==="disabled"){if(dashboard.publicPort!==null||dashboard.internalPort!==null||dashboard.tuiEnabled!==false){invalid("disabled Hermes dashboard must not configure ports or TUI")}return{agent,mode,url,publicPort:null,internalPort:null,tuiEnabled:false}}const publicPort=requirePort(dashboard.publicPort,"dashboard.publicPort",1024);const internalPort=requirePort(dashboard.internalPort,"dashboard.internalPort",1024);if(publicPort===internalPort){invalid("Hermes dashboard publicPort and internalPort must differ")}if(isHermesReservedApiPort(publicPort)||isHermesReservedApiPort(internalPort)){invalid(`Hermes dashboard ports must not use reserved API ports ${HERMES_RESERVED_API_PORT_LABEL}`)}if(configuredDashboardPort(url)!==publicPort){invalid("Hermes dashboard.publicPort must match dashboard.url")}return{agent,mode,url,publicPort,internalPort,tuiEnabled:requireBoolean(dashboard.tuiEnabled,"dashboard.tuiEnabled")}}if(agent==="pi"){rejectUnknownKeys(dashboard,PI_DASHBOARD_KEYS,"dashboard");if(dashboard.mode!=="disabled")invalid("pi dashboard.mode must be disabled");return{agent,mode:"disabled"}}rejectUnknownKeys(dashboard,DCODE_DASHBOARD_KEYS,"dashboard");if(dashboard.mode!=="disabled"){invalid("langchain-deepagents-code dashboard.mode must be disabled")}return{agent,mode:"disabled"}}function validateInference(value,agent){const inference=requireRecord(value,"inference");rejectUnknownKeys(inference,INFERENCE_KEYS,"inference");const routeProvider=requireBoundedString(inference.routeProvider,"inference.routeProvider");const upstreamProvider=requireBoundedString(inference.upstreamProvider,"inference.upstreamProvider");const model=requireBoundedString(inference.model,"inference.model",MAX_MODEL_BYTES);const api=requireStringEnum(inference.api,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inferenceApis),"inference.api");const upstreamEndpointUrl=inference.upstreamEndpointUrl===null?null:requireHttpUrl(inference.upstreamEndpointUrl,"inference.upstreamEndpointUrl");const primaryModelRef=inference.primaryModelRef===null?null:requireBoundedString(inference.primaryModelRef,"inference.primaryModelRef",MAX_MODEL_BYTES);const compatibility=requireJsonObjectOrNull(inference.compatibility,"inference.compatibility");const inputModalities=inference.inputModalities===null?null:requireEnumList(inference.inputModalities,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].inputModalities),"inference.inputModalities",{allowEmpty:false});if(upstreamEndpointUrl!==null&&!MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].supportsUpstreamEndpoint){invalid(`inference.upstreamEndpointUrl must be null for ${agent}`)}if(agent==="openclaw"){if(primaryModelRef===null||inputModalities===null){invalid("openclaw requires primaryModelRef and inputModalities")}if(primaryModelRef!==`${routeProvider}/${model}`){invalid("openclaw primaryModelRef must match routeProvider and model")}}else{if(primaryModelRef!==null||compatibility!==null||inputModalities!==null){invalid(`${agent} does not support primaryModelRef, compatibility, or inputModalities`)}if(agent==="langchain-deepagents-code"&&!isValidDcodeUpstreamProvider(upstreamProvider)){invalid("inference.upstreamProvider must start with an ASCII letter or digit and contain 1-64 ASCII letters, digits, dots, underscores, or hyphens for DCode")}}return{routeProvider,upstreamProvider,model,routedBaseUrl:requireHttpUrl(inference.routedBaseUrl,"inference.routedBaseUrl"),upstreamEndpointUrl,api,primaryModelRef,compatibility,inputModalities}}function validateProxy(value,agent){const proxy=requireRecord(value,"proxy");rejectUnknownKeys(proxy,PROXY_KEYS,"proxy");const hostHttpUrl=requireProxyUrl(proxy.hostHttpUrl,new Set(["http:"]),"proxy.hostHttpUrl");const hostHttpsUrl=requireProxyUrl(proxy.hostHttpsUrl,new Set(["http:","https:"]),"proxy.hostHttpsUrl");const hostNoProxy=requireStringList(proxy.hostNoProxy,"proxy.hostNoProxy");if(!MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].supportsHostProxyIntent&&(hostHttpUrl!==null||hostHttpsUrl!==null||hostNoProxy.length>0)){invalid(`${agent} rejects host proxy intent and accepts only its root-owned managed route`)}return{managedHost:requireManagedProxyHost(proxy.managedHost,"proxy.managedHost"),managedPort:requirePort(proxy.managedPort,"proxy.managedPort"),hostHttpUrl,hostHttpsUrl,hostNoProxy}}function validateTools(value,agent){const tools=requireRecord(value,"tools");rejectUnknownKeys(tools,TOOLS_KEYS,"tools");const enabledGateways=requireEnumList(tools.enabledGateways,new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].toolGateways),"tools.enabledGateways",{allowEmpty:true});return{disclosure:requireStringEnum(tools.disclosure,new Set(["progressive","direct"]),"tools.disclosure"),enabledGateways}}function validateTuning(value,agent){const tuning=requireRecord(value,"tuning");rejectUnknownKeys(tuning,TUNING_KEYS,"tuning");const result={contextWindow:requireNullablePositiveInteger(tuning.contextWindow,"tuning.contextWindow"),maxTokens:requireNullablePositiveInteger(tuning.maxTokens,"tuning.maxTokens"),reasoning:requireNullableBoolean(tuning.reasoning,"tuning.reasoning"),reasoningEffort:tuning.reasoningEffort===null?null:requireStringEnum(tuning.reasoningEffort,REASONING_EFFORT_SET,"tuning.reasoningEffort")};const advertised=new Set(MANAGED_STARTUP_PROFILE_CAPABILITIES[agent].tuningFields);const unsupported=TUNING_FIELD_ORDER.filter(field=>result[field]!==null&&!advertised.has(field));if(unsupported.length>0){invalid(`${agent} does not support startup tuning fields: ${unsupported.join(", ")}`)}if(agent==="openclaw"){const missing=TUNING_FIELD_ORDER.filter(field=>advertised.has(field)&&result[field]===null);if(missing.length>0){invalid(`openclaw requires ${missing.join(", ")} tuning`)}}if(agent==="hermes"&&result.contextWindow!==null&&result.contextWindowcanonicalizeJson(item));if(!isPlainObject(value))return value;const result={};const keys=sortStrings(Object.keys(value));for(let index=0;indexMANAGED_STARTUP_PROFILE_MAX_BYTES){invalid(`canonical payload exceeds ${String(MANAGED_STARTUP_PROFILE_MAX_BYTES)} bytes`)}return serialized}function decodeManagedStartupProfile(encoded){if(typeof encoded!=="string"||encoded.length===0||import_node_buffer.Buffer.byteLength(encoded,"ascii")>MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES||!BASE64URL_RE.test(encoded)||encoded.length%4===1){invalid("encoded payload is malformed or exceeds the size limit")}const bytes=import_node_buffer.Buffer.from(encoded,"base64url");if(bytes.length===0||bytes.length>MANAGED_STARTUP_PROFILE_MAX_BYTES||bytes.toString("base64url")!==encoded){invalid("encoded payload is malformed or exceeds the size limit")}let raw;try{raw=UTF8_DECODER.decode(bytes)}catch{invalid("payload is not valid UTF-8")}let parsed;try{parsed=JSON.parse(raw)}catch{invalid("payload is not valid JSON")}const profile=validateManagedStartupProfile(parsed);if(serializeManagedStartupProfile(profile)!==raw){invalid("payload is not in canonical form")}return profile}function fingerprintManagedStartupProfile(profile){return(0,import_node_crypto2.createHash)("sha256").update(serializeManagedStartupProfile(profile),"utf8").digest("hex")}var ManagedStartupAgentEnvironmentError=class extends Error{constructor(message){super(`Cannot map managed startup profile: ${message}`);this.name="ManagedStartupAgentEnvironmentError"}};var EMPTY_APPLICATION_ENVIRONMENT=Object.freeze({});var OPENCLAW_APPLICATION_RUNTIME_INPUTS=Object.freeze([["NEMOCLAW_AUTO_PAIR_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS","positive-safe-integer"],["NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS","positive-finite-seconds"],["NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS","positive-finite-seconds"]]);function booleanFlag(value){return value?"1":"0"}function canonicalizeJson2(value){if(Array.isArray(value))return value.map(item=>canonicalizeJson2(item));if(value===null||typeof value!=="object")return value;const record=value;return Object.fromEntries(Object.keys(record).sort().map(key=>[key,canonicalizeJson2(record[key])]))}function encodeCanonicalJson(value){return import_node_buffer2.Buffer.from(JSON.stringify(canonicalizeJson2(value)),"utf8").toString("base64")}function sortedEnvironment(environment){return Object.freeze(Object.fromEntries(Object.entries(environment).sort(([left],[right])=>leftright?1:0)))}function canonicalApplicationRuntimeValue(name,raw,kind){if(raw.includes("\0")||/[\r\n]/u.test(raw)){throw new ManagedStartupAgentEnvironmentError(`${name} must be single-line text`)}const value=Number(raw.trim());const valid=kind==="positive-safe-integer"?Number.isSafeInteger(value)&&value>0:Number.isFinite(value)&&value>0;if(!valid){throw new ManagedStartupAgentEnvironmentError(`${name} must be ${kind==="positive-safe-integer"?"a positive safe integer":"finite positive seconds"}`)}return String(value)}function applicationRuntimePlan(profile,environment){const exportEnvironment={};if(profile.agent==="openclaw"){for(const[name,kind]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){const raw=environment[name];if(raw!==void 0){exportEnvironment[name]=canonicalApplicationRuntimeValue(name,raw,kind)}}}const unsetEnvironment=new Set(MANAGED_STARTUP_RUNTIME_CLEANUP_OBLIGATIONS.filter(({supportedFor})=>!supportedFor.includes(profile.agent)).map(({input})=>input));if(profile.agent!=="openclaw"){for(const[name]of OPENCLAW_APPLICATION_RUNTIME_INPUTS){unsetEnvironment.add(name)}}return Object.freeze({exportEnvironment:sortedEnvironment(exportEnvironment),unsetEnvironment:Object.freeze([...unsetEnvironment].sort())})}function commonConfigurationEnvironment(profile){return{NEMOCLAW_INFERENCE_API:profile.inference.api,NEMOCLAW_INFERENCE_BASE_URL:profile.inference.routedBaseUrl,NEMOCLAW_INFERENCE_PROVIDER_ID:profile.inference.routeProvider,NEMOCLAW_MODEL:profile.inference.model,NEMOCLAW_TOOL_DISCLOSURE:profile.tools.disclosure,NEMOCLAW_UPSTREAM_PROVIDER:profile.inference.upstreamProvider}}function appendHostProxyEnvironment(environment,profile,options={}){if(options.preserveAmbientWhenAbsent===true&&profile.proxy.hostHttpUrl===null&&profile.proxy.hostHttpsUrl===null&&profile.proxy.hostNoProxy.length===0){return}const httpProxy=profile.proxy.hostHttpUrl??"";const httpsProxy=profile.proxy.hostHttpsUrl??"";const noProxy=profile.proxy.hostNoProxy.join(",");environment.HTTP_PROXY=httpProxy;environment.HTTPS_PROXY=httpsProxy;environment.NO_PROXY=noProxy;environment.http_proxy=httpProxy;environment.https_proxy=httpsProxy;environment.no_proxy=noProxy}function messagingEnvironment(profile,expectedAgent){if(profile.messaging.plan===null)return{};const plan=parseSandboxMessagingPlan(profile.messaging.plan,{agent:expectedAgent});if(!plan){throw new ManagedStartupAgentEnvironmentError(`messaging.plan must contain a validated ${expectedAgent} messaging plan`)}const{workflow:_workflow,...imageBuildPlan}=plan;return{NEMOCLAW_MESSAGING_PLAN_B64:encodeCanonicalJson(imageBuildPlan)}}function corporateCaMaterial(profile){return Object.freeze({kind:"corporate-ca-handoff",legacyInput:"NEMOCLAW_CORPORATE_CA_B64",expectedSha256:profile.corporateCa.bundleSha256})}function rootOwnedFile(legacyInput,path5,value){return Object.freeze({kind:"root-owned-file",legacyInput,path:path5,contents:`${value} `,owner:"root",group:"root",mode:292})}function dashboardAction(dashboard){return Object.freeze({kind:"configure-dashboard",dashboard:Object.freeze(structuredClone(dashboard))})}function applicationActions(profile,messagingAgent){const actions=[];if(messagingAgent!==null){actions.push(Object.freeze({kind:"apply-messaging-plan",agent:messagingAgent,mode:profile.messaging.plan===null?"clear":"apply",phase:"runtime-setup",runAs:"root"}))}actions.push(Object.freeze({kind:"generate-agent-config",agent:profile.agent,runAs:"sandbox"}));if(messagingAgent!==null){actions.push(Object.freeze({kind:"apply-messaging-plan",agent:messagingAgent,mode:profile.messaging.plan===null?"clear":"apply",phase:"post-agent-install",runAs:"sandbox"}))}actions.push(dashboardAction(profile.dashboard));return Object.freeze(actions)}function mapOpenClawProfile(profile,environment){if(profile.agent!=="openclaw"||profile.agentConfig.agent!=="openclaw"||profile.dashboard.agent!=="openclaw"||profile.inference.primaryModelRef===null||profile.inference.inputModalities===null||profile.tuning.contextWindow===null||profile.tuning.maxTokens===null||profile.tuning.reasoning===null||profile.tuning.reasoningEffort===null){throw new ManagedStartupAgentEnvironmentError("OpenClaw profile state is inconsistent")}const configurationEnvironment={...commonConfigurationEnvironment(profile),...messagingEnvironment(profile,"openclaw"),CHAT_UI_URL:profile.dashboard.url,NEMOCLAW_AGENT_HEARTBEAT_EVERY:profile.agentConfig.heartbeatEvery??"",NEMOCLAW_AGENT_TIMEOUT:String(profile.agentConfig.agentTimeoutSeconds),NEMOCLAW_CONTEXT_WINDOW:String(profile.tuning.contextWindow),NEMOCLAW_DASHBOARD_BIND:profile.dashboard.bindAddress==="0.0.0.0"?profile.dashboard.bindAddress:"",NEMOCLAW_DISABLE_DEVICE_AUTH:booleanFlag(profile.agentConfig.deviceAuth.disabled),NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE:profile.agentConfig.deviceAuth.optOutSource,NEMOCLAW_EXTRA_AGENTS_JSON_B64:encodeCanonicalJson(profile.agentConfig.extraAgents),NEMOCLAW_INFERENCE_COMPAT_B64:encodeCanonicalJson(profile.inference.compatibility),NEMOCLAW_INFERENCE_INPUTS:profile.inference.inputModalities.join(","),NEMOCLAW_MAX_TOKENS:String(profile.tuning.maxTokens),NEMOCLAW_OPENCLAW_OTEL:booleanFlag(profile.agentConfig.otel.enabled),NEMOCLAW_OPENCLAW_OTEL_ENDPOINT:profile.agentConfig.otel.endpointUrl,NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE:String(profile.agentConfig.otel.sampleRate),NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME:profile.agentConfig.otel.serviceName,NEMOCLAW_PRIMARY_MODEL_REF:profile.inference.primaryModelRef,NEMOCLAW_PROXY_HOST:profile.proxy.managedHost,NEMOCLAW_PROXY_PORT:String(profile.proxy.managedPort),NEMOCLAW_REASONING:String(profile.tuning.reasoning),NEMOCLAW_REASONING_EFFORT:profile.tuning.reasoningEffort,NEMOCLAW_WEB_SEARCH_ENABLED:booleanFlag(profile.agentConfig.webSearch.enabled),NEMOCLAW_WEB_SEARCH_PROVIDER:profile.agentConfig.webSearch.provider,NEMOCLAW_WSL_DASHBOARD_EXPOSURE:booleanFlag(profile.dashboard.wslExposure)};const runtimeEnvironment={...configurationEnvironment};delete runtimeEnvironment.NEMOCLAW_MESSAGING_PLAN_B64;runtimeEnvironment.NEMOCLAW_DASHBOARD_PORT=String(profile.dashboard.port);runtimeEnvironment.NEMOCLAW_MINIMAL_BOOTSTRAP=booleanFlag(profile.agentConfig.minimalBootstrap);appendHostProxyEnvironment(runtimeEnvironment,profile,{preserveAmbientWhenAbsent:true});return Object.freeze({schemaVersion:profile.schemaVersion,agent:profile.agent,configurationEnvironment:sortedEnvironment(configurationEnvironment),runtimeEnvironment:sortedEnvironment(runtimeEnvironment),applicationRuntime:applicationRuntimePlan(profile,environment),materials:Object.freeze([corporateCaMaterial(profile)]),actions:applicationActions(profile,"openclaw")})}function mapHermesProfile(profile,environment){if(profile.agent!=="hermes"||profile.agentConfig.agent!=="hermes"||profile.dashboard.agent!=="hermes"){throw new ManagedStartupAgentEnvironmentError("Hermes profile state is inconsistent")}const configurationEnvironment={...commonConfigurationEnvironment(profile),...messagingEnvironment(profile,"hermes"),CHAT_UI_URL:profile.dashboard.url,NEMOCLAW_CONTEXT_WINDOW:profile.tuning.contextWindow===null?"":String(profile.tuning.contextWindow),NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER:booleanFlag(profile.tools.enabledGateways.length>0),NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64:encodeCanonicalJson(profile.tools.enabledGateways),NEMOCLAW_WEB_SEARCH_ENABLED:booleanFlag(profile.agentConfig.webSearch.enabled),NEMOCLAW_WEB_SEARCH_PROVIDER:profile.agentConfig.webSearch.provider};const runtimeEnvironment={...configurationEnvironment};delete runtimeEnvironment.NEMOCLAW_MESSAGING_PLAN_B64;runtimeEnvironment.NEMOCLAW_DASHBOARD_PORT=profile.dashboard.publicPort===null?"":String(profile.dashboard.publicPort);runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD=profile.dashboard.mode==="loopback-forwarded"?"1":"0";runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_INTERNAL_PORT=profile.dashboard.internalPort===null?"":String(profile.dashboard.internalPort);runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_PORT=profile.dashboard.publicPort===null?"":String(profile.dashboard.publicPort);runtimeEnvironment.NEMOCLAW_HERMES_DASHBOARD_TUI=booleanFlag(profile.dashboard.tuiEnabled);runtimeEnvironment.NEMOCLAW_PROXY_HOST=profile.proxy.managedHost;runtimeEnvironment.NEMOCLAW_PROXY_PORT=String(profile.proxy.managedPort);appendHostProxyEnvironment(runtimeEnvironment,profile,{preserveAmbientWhenAbsent:true});return Object.freeze({schemaVersion:profile.schemaVersion,agent:profile.agent,configurationEnvironment:sortedEnvironment(configurationEnvironment),runtimeEnvironment:sortedEnvironment(runtimeEnvironment),applicationRuntime:applicationRuntimePlan(profile,environment),materials:Object.freeze([corporateCaMaterial(profile)]),actions:applicationActions(profile,"hermes")})}function mapDcodeProfile(profile,environment){if(profile.agent!=="langchain-deepagents-code"||profile.agentConfig.agent!=="langchain-deepagents-code"||profile.dashboard.agent!=="langchain-deepagents-code"||profile.messaging.plan!==null){throw new ManagedStartupAgentEnvironmentError("LangChain Deep Agents Code profile state is inconsistent")}const reasoningEffort=profile.tuning.reasoningEffort===null||profile.tuning.reasoningEffort==="default"?"":profile.tuning.reasoningEffort;const configurationEnvironment={...commonConfigurationEnvironment(profile),NEMOCLAW_REASONING_EFFORT:reasoningEffort,NEMOCLAW_UPSTREAM_ENDPOINT_URL:profile.inference.upstreamEndpointUrl??""};appendHostProxyEnvironment(configurationEnvironment,profile);const runtimeEnvironment={...configurationEnvironment,NEMOCLAW_OBSERVABILITY:booleanFlag(profile.agentConfig.observabilityEnabled)};delete runtimeEnvironment.NEMOCLAW_INFERENCE_BASE_URL;delete runtimeEnvironment.NEMOCLAW_REASONING_EFFORT;delete runtimeEnvironment.NEMOCLAW_UPSTREAM_PROVIDER;for(const name of["HTTP_PROXY","HTTPS_PROXY","NO_PROXY","http_proxy","https_proxy","no_proxy"]){delete runtimeEnvironment[name]}const materials=Object.freeze([corporateCaMaterial(profile),rootOwnedFile("NEMOCLAW_DCODE_AUTO_APPROVAL","/usr/local/share/nemoclaw/dcode-auto-approval",profile.agentConfig.autoApprovalMode),rootOwnedFile("NEMOCLAW_INFERENCE_BASE_URL","/usr/local/share/nemoclaw/dcode-inference-base-url",profile.inference.routedBaseUrl),rootOwnedFile("NEMOCLAW_UPSTREAM_PROVIDER","/usr/local/share/nemoclaw/dcode-upstream-provider",profile.inference.upstreamProvider),rootOwnedFile("NEMOCLAW_PROXY_HOST","/usr/local/share/nemoclaw/dcode-proxy-host",profile.proxy.managedHost),rootOwnedFile("NEMOCLAW_PROXY_PORT","/usr/local/share/nemoclaw/dcode-proxy-port",String(profile.proxy.managedPort)),rootOwnedFile("NEMOCLAW_REASONING_EFFORT","/usr/local/share/nemoclaw/dcode-reasoning-effort",reasoningEffort)]);return Object.freeze({schemaVersion:profile.schemaVersion,agent:profile.agent,configurationEnvironment:sortedEnvironment(configurationEnvironment),runtimeEnvironment:sortedEnvironment(runtimeEnvironment),applicationRuntime:applicationRuntimePlan(profile,environment),materials,actions:applicationActions(profile,null)})}function mapPiProfile(profile,environment){if(profile.agent!=="pi"||profile.agentConfig.agent!=="pi"||profile.dashboard.agent!=="pi"||profile.messaging.plan!==null){throw new ManagedStartupAgentEnvironmentError("Pi profile state is inconsistent")}const configurationEnvironment={...commonConfigurationEnvironment(profile),NEMOCLAW_CONTEXT_WINDOW:profile.tuning.contextWindow===null?"":String(profile.tuning.contextWindow),NEMOCLAW_MAX_TOKENS:profile.tuning.maxTokens===null?"":String(profile.tuning.maxTokens),NEMOCLAW_REASONING:profile.tuning.reasoning===null?"":String(profile.tuning.reasoning)};appendHostProxyEnvironment(configurationEnvironment,profile);const runtimeEnvironment={...configurationEnvironment};delete runtimeEnvironment.NEMOCLAW_INFERENCE_BASE_URL;delete runtimeEnvironment.NEMOCLAW_CONTEXT_WINDOW;delete runtimeEnvironment.NEMOCLAW_MAX_TOKENS;delete runtimeEnvironment.NEMOCLAW_REASONING;for(const name of["HTTP_PROXY","HTTPS_PROXY","NO_PROXY","http_proxy","https_proxy","no_proxy"]){delete runtimeEnvironment[name]}const materials=Object.freeze([corporateCaMaterial(profile),rootOwnedFile("NEMOCLAW_PROXY_HOST","/usr/local/share/nemoclaw/pi-proxy-host",profile.proxy.managedHost),rootOwnedFile("NEMOCLAW_PROXY_PORT","/usr/local/share/nemoclaw/pi-proxy-port",String(profile.proxy.managedPort))]);return Object.freeze({schemaVersion:profile.schemaVersion,agent:profile.agent,configurationEnvironment:sortedEnvironment(configurationEnvironment),runtimeEnvironment:sortedEnvironment(runtimeEnvironment),applicationRuntime:applicationRuntimePlan(profile,environment),materials,actions:applicationActions(profile,null)})}function mapManagedStartupProfileToAgentEnvironment(profile,environment=EMPTY_APPLICATION_ENVIRONMENT){const validated=validateManagedStartupProfile(profile);switch(validated.agent){case"openclaw":return mapOpenClawProfile(validated,environment);case"hermes":return mapHermesProfile(validated,environment);case"langchain-deepagents-code":return mapDcodeProfile(validated,environment);case"pi":return mapPiProfile(validated,environment)}}var import_node_buffer3=require("node:buffer");var import_node_crypto3=require("node:crypto");var import_node_fs=__toESM(require("node:fs"));var import_node_path=__toESM(require("node:path"));var import_node_util2=require("node:util");var MANAGED_STARTUP_APPLICATION_STATE_DIR="/var/lib/nemoclaw/startup-profile";var MANAGED_STARTUP_CA_MAX_BYTES=128*1024;var MANAGED_STARTUP_CA_MAX_CERTIFICATES=24;var STATE_SCHEMA_VERSION=1;var STATE_DIRECTORY_MODE=448;var STATE_FILE_MODE=384;var MAX_CONTROL_FILE_BYTES=512;var MAX_STATE_ENTRIES=32;var SHA256_RE2=/^[a-f0-9]{64}$/u;var GENERATION_RE=/^generation-([a-f0-9]{64})$/u;var PREPARE_TEMP_RE=/^\.prepare-[0-9]+-[a-f0-9]{24}$/u;var CONTROL_TEMP_RE=/^\.(?:committed|pending)\.json-[a-f0-9]{24}\.tmp$/u;var PEM_CERTIFICATE_RE=/-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+?-----END CERTIFICATE-----/gu;var UTF8_DECODER2=new import_node_util2.TextDecoder("utf-8",{fatal:true});var DEFAULT_RUNTIME={rootUid:0,rootGid:0};var ManagedStartupApplicationError=class extends Error{constructor(message){super(`Managed startup application failed: ${message}`);this.name="ManagedStartupApplicationError"}};function fail(message){throw new ManagedStartupApplicationError(message)}function runtimeFor(override){return override??DEFAULT_RUNTIME}function requireContainerRoot(){if(process.geteuid?.()!==0){fail("the image-side applicator must run with effective uid 0")}}function modeOf(stat){return stat.mode&511}function requireOwner(stat,target,runtime){if(stat.uid!==runtime.rootUid||stat.gid!==runtime.rootGid){fail(`${target} must be owned by root:root`)}}function requireSecureDirectory(target,runtime,exactMode){let stat;try{stat=import_node_fs.default.lstatSync(target)}catch{fail(`state directory component is missing or unreadable: ${target}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail(`state directory component must be a real directory: ${target}`)}const runtimeOwned=stat.uid===runtime.rootUid&&stat.gid===runtime.rootGid;const systemRootOwned=stat.uid===0&&stat.gid===0;if(exactMode){requireOwner(stat,target,runtime)}else if(!runtimeOwned&&!systemRootOwned){fail(`state directory ancestor is not owned by a trusted identity: ${target}`)}const mode=modeOf(stat);const writableByUntrustedIdentity=(mode&18)!==0;const trustedStickyRoot=(stat.mode&512)!==0&&(runtimeOwned||systemRootOwned);if(exactMode&&mode!==STATE_DIRECTORY_MODE||!exactMode&&writableByUntrustedIdentity&&!trustedStickyRoot){fail(exactMode?`${target} must have mode 0700`:`${target} is a replaceable group- or world-writable ancestor`)}}function requireSecureAncestors(target,runtime){const root=import_node_path.default.parse(target).root;let current=root;requireSecureDirectory(current,runtime,false);for(const segment of import_node_path.default.relative(root,target).split(import_node_path.default.sep).filter(Boolean)){current=import_node_path.default.join(current,segment);let stat;try{stat=import_node_fs.default.lstatSync(current)}catch{fail(`state directory component is missing or unreadable: ${current}`)}if(stat.isSymbolicLink()){const runtimeOwned=stat.uid===runtime.rootUid&&stat.gid===runtime.rootGid;const systemRootOwned=stat.uid===0&&stat.gid===0;if(!runtimeOwned&&!systemRootOwned){fail(`state directory ancestor is a replaceable symlink: ${current}`)}let resolved;try{resolved=import_node_fs.default.realpathSync(current)}catch{fail(`state directory symlink is missing or unreadable: ${current}`)}requireSecureAncestors(resolved,runtime);continue}requireSecureDirectory(current,runtime,false)}}function ensureStateDirectory(rawStateDirectory,runtime){const stateDirectory=rawStateDirectory??MANAGED_STARTUP_APPLICATION_STATE_DIR;if(!import_node_path.default.isAbsolute(stateDirectory)||stateDirectory.includes("\0")){fail("stateDirectory must be an absolute path")}const normalized=import_node_path.default.resolve(stateDirectory);const parent=import_node_path.default.dirname(normalized);requireSecureAncestors(parent,runtime);try{import_node_fs.default.mkdirSync(normalized,{mode:STATE_DIRECTORY_MODE});import_node_fs.default.chownSync(normalized,runtime.rootUid,runtime.rootGid);import_node_fs.default.chmodSync(normalized,STATE_DIRECTORY_MODE)}catch(error){if(error.code!=="EEXIST"){fail(`could not create the managed startup state directory: ${normalized}`)}}requireSecureDirectory(normalized,runtime,true);return normalized}function requireSecureRegularFileStat(stat,target,runtime){if(!stat.isFile()||stat.isSymbolicLink()){fail(`${target} must be a regular file`)}if(stat.nlink!==1){fail(`${target} must not be hardlinked`)}requireOwner(stat,target,runtime);if(modeOf(stat)!==STATE_FILE_MODE){fail(`${target} must have mode 0600`)}}function readSecureFile(target,maxBytes,runtime){let descriptor;try{descriptor=import_node_fs.default.openSync(target,import_node_fs.default.constants.O_RDONLY|import_node_fs.default.constants.O_NOFOLLOW)}catch{fail(`state file is missing, unreadable, or a symlink: ${target}`)}try{const stat=import_node_fs.default.fstatSync(descriptor);requireSecureRegularFileStat(stat,target,runtime);if(stat.size<1||stat.size>maxBytes){fail(`${target} is empty or exceeds its size limit`)}const content=import_node_fs.default.readFileSync(descriptor);if(content.length!==stat.size){fail(`${target} changed while it was being read`)}return content}finally{import_node_fs.default.closeSync(descriptor)}}function writeSecureNewFile(target,content,runtime){let descriptor;try{descriptor=import_node_fs.default.openSync(target,import_node_fs.default.constants.O_CREAT|import_node_fs.default.constants.O_EXCL|import_node_fs.default.constants.O_WRONLY|import_node_fs.default.constants.O_NOFOLLOW,STATE_FILE_MODE)}catch{fail(`refused to replace an existing state file: ${target}`)}try{import_node_fs.default.fchownSync(descriptor,runtime.rootUid,runtime.rootGid);import_node_fs.default.fchmodSync(descriptor,STATE_FILE_MODE);import_node_fs.default.writeFileSync(descriptor,content);import_node_fs.default.fsyncSync(descriptor)}finally{import_node_fs.default.closeSync(descriptor)}}function syncDirectory(target){const descriptor=import_node_fs.default.openSync(target,import_node_fs.default.constants.O_RDONLY);try{import_node_fs.default.fsyncSync(descriptor)}finally{import_node_fs.default.closeSync(descriptor)}}function randomToken(){return(0,import_node_crypto3.randomBytes)(12).toString("hex")}function stateControl(fingerprint){return{schemaVersion:STATE_SCHEMA_VERSION,fingerprint,generation:`generation-${fingerprint}`}}function serializeStateControl(control){return JSON.stringify({fingerprint:control.fingerprint,generation:control.generation,schemaVersion:control.schemaVersion})}function parseStateControl(target,runtime){const bytes=readSecureFile(target,MAX_CONTROL_FILE_BYTES,runtime);let raw;try{raw=UTF8_DECODER2.decode(bytes)}catch{fail(`${target} is not valid UTF-8`)}let parsed;try{parsed=JSON.parse(raw)}catch{fail(`${target} is not valid JSON`)}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail(`${target} does not contain a valid state control`)}const record=parsed;if(Object.keys(record).sort().join(",")!=="fingerprint,generation,schemaVersion"||record.schemaVersion!==STATE_SCHEMA_VERSION||typeof record.fingerprint!=="string"||!SHA256_RE2.test(record.fingerprint)||record.generation!==`generation-${record.fingerprint}`){fail(`${target} does not contain a valid state control`)}const control=stateControl(record.fingerprint);if(serializeStateControl(control)!==raw){fail(`${target} is not in canonical form`)}return control}function publishStateControlIfAbsent(stateDirectory,basename,control,runtime){const target=import_node_path.default.join(stateDirectory,basename);const temporary=import_node_path.default.join(stateDirectory,`.${basename}-${randomToken()}.tmp`);writeSecureNewFile(temporary,serializeStateControl(control),runtime);try{import_node_fs.default.linkSync(temporary,target)}catch(error){try{unlinkSecureControlOrTemp(temporary,runtime)}catch{}if(error.code==="EEXIST"){return{control:parseStateControl(target,runtime),created:false}}fail(`could not atomically publish ${basename}`)}try{import_node_fs.default.unlinkSync(temporary)}catch(error){if(error.code!=="ENOENT"){fail(`could not finalize atomic publication of ${basename}`)}}syncDirectory(stateDirectory);return{control,created:true}}function validateCorporateCaBytes(bytes){if(bytes.length<1||bytes.length>MANAGED_STARTUP_CA_MAX_BYTES){fail(`corporate CA bundle must contain 1-${String(MANAGED_STARTUP_CA_MAX_BYTES)} bytes`)}let pem;try{pem=UTF8_DECODER2.decode(bytes)}catch{fail("corporate CA bundle must be valid UTF-8 PEM")}const matches=[...pem.matchAll(PEM_CERTIFICATE_RE)];if(matches.length<1||matches.length>MANAGED_STARTUP_CA_MAX_CERTIFICATES||matches[0]?.index!==0){fail(`corporate CA bundle must contain 1-${String(MANAGED_STARTUP_CA_MAX_CERTIFICATES)} PEM CA certificates`)}let cursor=0;for(const match of matches){const index=match.index;if(index===void 0||!/^(?:\r?\n)+$/u.test(pem.slice(cursor,index))&&index!==0){fail("corporate CA bundle contains non-PEM material between certificates")}const block=match[0];let certificate;try{certificate=new import_node_crypto3.X509Certificate(block)}catch{fail("corporate CA bundle contains an invalid X.509 certificate")}if(!certificate.ca){fail("corporate CA bundle contains a certificate without basicConstraints CA:TRUE")}cursor=index+block.length}if(!/^(?:\r?\n)?$/u.test(pem.slice(cursor))){fail("corporate CA bundle contains trailing non-PEM material")}}function validateManagedStartupCorporateCaTransport(encoded,profile){const expectedDigest=profile.corporateCa.bundleSha256;if(expectedDigest===null){if(encoded!==void 0){fail("corporate CA transport must be absent when the profile has no CA digest")}return null}if(typeof encoded!=="string"||encoded.length===0||encoded.length>Math.ceil(MANAGED_STARTUP_CA_MAX_BYTES/3)*4||!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded)){fail("corporate CA transport must be canonical standard base64")}const bytes=import_node_buffer3.Buffer.from(encoded,"base64");if(bytes.toString("base64")!==encoded){fail("corporate CA transport must be canonical standard base64")}validateCorporateCaBytes(bytes);const actualDigest=(0,import_node_crypto3.createHash)("sha256").update(bytes).digest("hex");if(actualDigest!==expectedDigest){fail("corporate CA bundle does not match the profile SHA-256 digest")}return bytes}function readCanonicalProfile(profilePath,runtime){const bytes=readSecureFile(profilePath,MANAGED_STARTUP_PROFILE_MAX_BYTES,runtime);let raw;try{raw=UTF8_DECODER2.decode(bytes)}catch{fail(`${profilePath} is not valid UTF-8`)}let parsed;try{parsed=JSON.parse(raw)}catch{fail(`${profilePath} is not valid JSON`)}let profile;try{profile=validateManagedStartupProfile(parsed)}catch(error){fail(`${profilePath} is invalid: ${error.message}`)}if(serializeManagedStartupProfile(profile)!==raw){fail(`${profilePath} is not a canonical managed startup profile`)}return{profile,fingerprint:fingerprintManagedStartupProfile(profile)}}function validateGeneration(stateDirectory,control,runtime,expectedAgent){if(!GENERATION_RE.test(control.generation)){fail("state control names an invalid generation")}const directory=import_node_path.default.join(stateDirectory,control.generation);requireSecureDirectory(directory,runtime,true);const entries=import_node_fs.default.readdirSync(directory).sort();if(entries.some(entry=>entry!=="profile.json"&&entry!=="corporate-ca.pem")||!entries.includes("profile.json")){fail(`${directory} contains missing or unsupported state files`)}const profilePath=import_node_path.default.join(directory,"profile.json");const{profile,fingerprint}=readCanonicalProfile(profilePath,runtime);if(fingerprint!==control.fingerprint){fail(`${directory} does not match its recorded profile fingerprint`)}if(expectedAgent!==void 0&&profile.agent!==expectedAgent){fail(`managed startup profile targets ${profile.agent}, expected ${expectedAgent}`)}const caPath=import_node_path.default.join(directory,"corporate-ca.pem");let corporateCaPath=null;if(profile.corporateCa.bundleSha256===null){if(entries.includes("corporate-ca.pem")){fail(`${directory} contains a CA bundle that is absent from the profile`)}}else{if(!entries.includes("corporate-ca.pem")){fail(`${directory} is missing the CA bundle recorded by the profile`)}const caBytes=readSecureFile(caPath,MANAGED_STARTUP_CA_MAX_BYTES,runtime);validateCorporateCaBytes(caBytes);if((0,import_node_crypto3.createHash)("sha256").update(caBytes).digest("hex")!==profile.corporateCa.bundleSha256){fail(`${directory} contains a CA bundle with the wrong SHA-256 digest`)}corporateCaPath=caPath}return{directory,profilePath,corporateCaPath,profile,fingerprint}}function validateDisposableDirectory(target,runtime){requireSecureDirectory(target,runtime,true);const entries=import_node_fs.default.readdirSync(target);if(entries.length>2||entries.some(entry=>entry!=="profile.json"&&entry!=="corporate-ca.pem")){fail(`${target} is not a recognized disposable generation`)}for(const entry of entries){const file=import_node_path.default.join(target,entry);const stat=import_node_fs.default.lstatSync(file);requireSecureRegularFileStat(stat,file,runtime)}}function discardDirectory(target,runtime){validateDisposableDirectory(target,runtime);import_node_fs.default.rmSync(target,{recursive:true})}function discardDirectoryIfPresent(target,runtime){try{import_node_fs.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return false;fail(`could not inspect disposable generation ${target}`)}discardDirectory(target,runtime);return true}function unlinkSecureControlOrTemp(target,runtime){const stat=import_node_fs.default.lstatSync(target);requireSecureRegularFileStat(stat,target,runtime);if(stat.size>MAX_CONTROL_FILE_BYTES){fail(`${target} exceeds the state-control size limit`)}import_node_fs.default.unlinkSync(target)}function listStateEntries(stateDirectory){const entries=import_node_fs.default.readdirSync(stateDirectory).sort();if(entries.length>MAX_STATE_ENTRIES){fail(`state directory exceeds ${String(MAX_STATE_ENTRIES)} entries`)}return entries}function unlinkRecoverableControlTemp(stateDirectory,entry,runtime){const temporary=import_node_path.default.join(stateDirectory,entry);const stat=import_node_fs.default.lstatSync(temporary);if(stat.nlink===1){unlinkSecureControlOrTemp(temporary,runtime);return}const basename=entry.startsWith(".committed.json-")?"committed.json":entry.startsWith(".pending.json-")?"pending.json":null;const target=basename===null?null:import_node_path.default.join(stateDirectory,basename);let targetStat=null;try{targetStat=target===null?null:import_node_fs.default.lstatSync(target)}catch{fail(`refused to remove an unpaired atomic-control temporary file: ${temporary}`)}if(stat.nlink!==2||targetStat===null||stat.dev!==targetStat.dev||stat.ino!==targetStat.ino||!stat.isFile()||stat.isSymbolicLink()||modeOf(stat)!==STATE_FILE_MODE||stat.size<1||stat.size>MAX_CONTROL_FILE_BYTES){fail(`refused to remove an unpaired atomic-control temporary file: ${temporary}`)}requireOwner(stat,temporary,runtime);requireOwner(targetStat,target,runtime);import_node_fs.default.unlinkSync(temporary)}function cleanAtomicTemps(stateDirectory,entries,runtime){let changed=false;for(const entry of entries){const target=import_node_path.default.join(stateDirectory,entry);if(PREPARE_TEMP_RE.test(entry)){discardDirectory(target,runtime);changed=true}else if(CONTROL_TEMP_RE.test(entry)){unlinkRecoverableControlTemp(stateDirectory,entry,runtime);changed=true}}if(changed)syncDirectory(stateDirectory)}function requireKnownStateEntries(stateDirectory,entries){for(const entry of entries){if(entry==="committed.json"||entry==="pending.json"||GENERATION_RE.test(entry)||PREPARE_TEMP_RE.test(entry)||CONTROL_TEMP_RE.test(entry)){continue}fail(`${stateDirectory} contains unsupported state component ${entry}`)}}function discardGenerationsExcept(stateDirectory,keepGeneration,runtime){for(const entry of listStateEntries(stateDirectory)){if(GENERATION_RE.test(entry)&&entry!==keepGeneration){discardDirectoryIfPresent(import_node_path.default.join(stateDirectory,entry),runtime)}}}function optionalStateControl(stateDirectory,basename,runtime){const target=import_node_path.default.join(stateDirectory,basename);try{import_node_fs.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return null;fail(`could not inspect ${target}`)}return parseStateControl(target,runtime)}function removePendingControl(stateDirectory,runtime){try{unlinkSecureControlOrTemp(import_node_path.default.join(stateDirectory,"pending.json"),runtime)}catch(error){if(error.code==="ENOENT")return;throw error}syncDirectory(stateDirectory)}function stateControlsMatch(left,right){return left.fingerprint===right.fingerprint&&left.generation===right.generation}function recoverCommittedState(stateDirectory,committedControl,pendingControl,requested,expectedAgent,runtime){const committed=validateGeneration(stateDirectory,committedControl,runtime,expectedAgent);if(pendingControl)removePendingControl(stateDirectory,runtime);discardGenerationsExcept(stateDirectory,committedControl.generation,runtime);syncDirectory(stateDirectory);if(!stateControlsMatch(committedControl,requested)){fail("a different startup profile is already committed; recreate the sandbox to change it")}return committed}function recoverState(stateDirectory,requested,expectedAgent,runtime){const initialEntries=listStateEntries(stateDirectory);requireKnownStateEntries(stateDirectory,initialEntries);cleanAtomicTemps(stateDirectory,initialEntries,runtime);const initiallyCommittedControl=optionalStateControl(stateDirectory,"committed.json",runtime);const pendingControl=optionalStateControl(stateDirectory,"pending.json",runtime);const committedAfterPendingRead=optionalStateControl(stateDirectory,"committed.json",runtime);const committedControl=committedAfterPendingRead??initiallyCommittedControl;if(committedControl){return{committed:recoverCommittedState(stateDirectory,committedControl,pendingControl,requested,expectedAgent,runtime),pending:null}}if(pendingControl){if(stateControlsMatch(pendingControl,requested)){const pending=validateGeneration(stateDirectory,pendingControl,runtime,expectedAgent);const committedAfterPendingValidation=optionalStateControl(stateDirectory,"committed.json",runtime);if(committedAfterPendingValidation){return{committed:recoverCommittedState(stateDirectory,committedAfterPendingValidation,pendingControl,requested,expectedAgent,runtime),pending:null}}discardGenerationsExcept(stateDirectory,pendingControl.generation,runtime);return{committed:null,pending}}fail("a different startup profile is already pending; wait for it to commit or recreate")}return{committed:null,pending:null}}function createGeneration(stateDirectory,control,profileJson,corporateCa,runtime){const temporaryName=`.prepare-${String(process.pid)}-${randomToken()}`;const temporary=import_node_path.default.join(stateDirectory,temporaryName);const generation=import_node_path.default.join(stateDirectory,control.generation);let renameAttempted=false;try{import_node_fs.default.mkdirSync(temporary,{mode:STATE_DIRECTORY_MODE});import_node_fs.default.chownSync(temporary,runtime.rootUid,runtime.rootGid);import_node_fs.default.chmodSync(temporary,STATE_DIRECTORY_MODE);writeSecureNewFile(import_node_path.default.join(temporary,"profile.json"),profileJson,runtime);if(corporateCa){writeSecureNewFile(import_node_path.default.join(temporary,"corporate-ca.pem"),corporateCa,runtime)}syncDirectory(temporary);renameAttempted=true;import_node_fs.default.renameSync(temporary,generation);syncDirectory(stateDirectory)}catch(error){try{import_node_fs.default.lstatSync(temporary);discardDirectory(temporary,runtime)}catch{}if(error instanceof ManagedStartupApplicationError)throw error;if(renameAttempted&&(error.code==="EEXIST"||error.code==="ENOTEMPTY")){return validateGeneration(stateDirectory,control,runtime)}fail(`could not atomically prepare generation ${control.generation}`)}return validateGeneration(stateDirectory,control,runtime)}function toPrepared(status,stateDirectory,generation,expectedAgent){return{status,stateDirectory,generationDirectory:generation.directory,profilePath:generation.profilePath,corporateCaPath:generation.corporateCaPath,fingerprint:generation.fingerprint,expectedAgent,profile:generation.profile}}function prepareManagedStartupApplication(input,testRuntime){const runtime=runtimeFor(testRuntime);requireContainerRoot();let profile;try{profile=decodeManagedStartupProfile(input.encodedProfile)}catch(error){fail(error.message)}if(profile.agent!==input.expectedAgent){fail(`managed startup profile targets ${profile.agent}, expected ${input.expectedAgent}`)}const corporateCa=validateManagedStartupCorporateCaTransport(input.corporateCaB64,profile);const profileJson=serializeManagedStartupProfile(profile);const control=stateControl(fingerprintManagedStartupProfile(profile));const stateDirectory=ensureStateDirectory(input.stateDirectory,runtime);const recovered=recoverState(stateDirectory,control,input.expectedAgent,runtime);if(recovered.committed){return toPrepared("already-committed",stateDirectory,recovered.committed,input.expectedAgent)}if(recovered.pending){return toPrepared("prepared",stateDirectory,recovered.pending,input.expectedAgent)}const generation=createGeneration(stateDirectory,control,profileJson,corporateCa,runtime);const publication=publishStateControlIfAbsent(stateDirectory,"pending.json",control,runtime);if(publication.control.fingerprint!==control.fingerprint||publication.control.generation!==control.generation){discardDirectoryIfPresent(generation.directory,runtime);syncDirectory(stateDirectory);fail("a different startup profile won the pending-state transaction")}const committedAfterPublication=optionalStateControl(stateDirectory,"committed.json",runtime);if(committedAfterPublication){if(committedAfterPublication.fingerprint!==control.fingerprint||committedAfterPublication.generation!==control.generation){if(publication.created){removePendingControl(stateDirectory,runtime);discardDirectoryIfPresent(generation.directory,runtime);syncDirectory(stateDirectory)}fail("a different startup profile committed during pending-state publication")}const committedGeneration=validateGeneration(stateDirectory,committedAfterPublication,runtime,input.expectedAgent);removePendingControl(stateDirectory,runtime);discardGenerationsExcept(stateDirectory,committedAfterPublication.generation,runtime);return toPrepared("already-committed",stateDirectory,committedGeneration,input.expectedAgent)}const activeGeneration=publication.created?generation:validateGeneration(stateDirectory,publication.control,runtime,input.expectedAgent);return toPrepared("prepared",stateDirectory,activeGeneration,input.expectedAgent)}function validatePreparedHandle(handle){if(!import_node_path.default.isAbsolute(handle.stateDirectory)||!SHA256_RE2.test(handle.fingerprint)||handle.generationDirectory!==import_node_path.default.join(handle.stateDirectory,`generation-${handle.fingerprint}`)||handle.profilePath!==import_node_path.default.join(handle.generationDirectory,"profile.json")||handle.corporateCaPath!==null&&handle.corporateCaPath!==import_node_path.default.join(handle.generationDirectory,"corporate-ca.pem")){fail("prepared startup handle is malformed")}return stateControl(handle.fingerprint)}function commitManagedStartupApplication(prepared,testRuntime){const runtime=runtimeFor(testRuntime);requireContainerRoot();const requested=validatePreparedHandle(prepared);const stateDirectory=ensureStateDirectory(prepared.stateDirectory,runtime);const committedControl=optionalStateControl(stateDirectory,"committed.json",runtime);if(committedControl){if(committedControl.fingerprint!==requested.fingerprint||committedControl.generation!==requested.generation){fail("a different startup profile is already committed")}const generation2=validateGeneration(stateDirectory,committedControl,runtime,prepared.expectedAgent);return{...toPrepared("already-committed",stateDirectory,generation2,prepared.expectedAgent),status:"committed"}}const pendingControl=optionalStateControl(stateDirectory,"pending.json",runtime);if(!pendingControl||pendingControl.fingerprint!==requested.fingerprint||pendingControl.generation!==requested.generation){fail("the prepared startup generation is not the active pending generation")}const generation=validateGeneration(stateDirectory,pendingControl,runtime,prepared.expectedAgent);const publication=publishStateControlIfAbsent(stateDirectory,"committed.json",pendingControl,runtime);if(publication.control.fingerprint!==requested.fingerprint||publication.control.generation!==requested.generation){fail("a different startup profile won the committed-state transaction")}removePendingControl(stateDirectory,runtime);discardGenerationsExcept(stateDirectory,publication.control.generation,runtime);syncDirectory(stateDirectory);return{...toPrepared("already-committed",stateDirectory,generation,prepared.expectedAgent),status:"committed"}}var SHIPPED_AGENT_SET=new Set(MANAGED_STARTUP_AGENTS);var DEFAULT_DEPENDENCIES={prepareApplication:input=>prepareManagedStartupApplication(input),commitApplication:prepared=>commitManagedStartupApplication(prepared)};var ManagedStartupCoordinatorError=class extends Error{constructor(message){super(`Managed startup coordination failed: ${message}`);this.name="ManagedStartupCoordinatorError"}};function fail2(message){throw new ManagedStartupCoordinatorError(message)}function createAdapterRegistry(adapters2){const byAgent=new Map;for(const adapter of adapters2){if(typeof adapter!=="object"||adapter===null||!SHIPPED_AGENT_SET.has(adapter.agent)||typeof adapter.apply!=="function"){fail2("every adapter must identify one shipped agent and provide an apply function")}if(byAgent.has(adapter.agent)){fail2(`duplicate adapter registered for ${adapter.agent}`)}byAgent.set(adapter.agent,adapter)}const missing=MANAGED_STARTUP_AGENTS.filter(agent=>!byAgent.has(agent));if(missing.length>0){fail2(`missing adapter for ${missing.join(", ")}`)}if(byAgent.size!==MANAGED_STARTUP_AGENTS.length){fail2("adapter registry must contain exactly the shipped agents")}return Object.freeze(Object.fromEntries(MANAGED_STARTUP_AGENTS.map(agent=>{const adapter=byAgent.get(agent);if(!adapter)fail2(`missing adapter for ${agent}`);return[agent,adapter]})))}function requirePreparedIdentity(prepared,requestedAgent){if(prepared.expectedAgent!==requestedAgent||prepared.profile.agent!==requestedAgent){fail2(`prepared profile targets ${prepared.profile.agent}, expected ${requestedAgent}`)}}function adapterContext(prepared){return Object.freeze({agent:prepared.profile.agent,profile:prepared.profile,fingerprint:prepared.fingerprint,generationDirectory:prepared.generationDirectory,profilePath:prepared.profilePath,corporateCaPath:prepared.corporateCaPath})}async function coordinateManagedStartupApplication(input,adapters2,dependencies=DEFAULT_DEPENDENCIES){const registry=createAdapterRegistry(adapters2);const prepared=await dependencies.prepareApplication(input);requirePreparedIdentity(prepared,input.expectedAgent);if(prepared.status==="already-committed"){return{adapterApplied:false,application:await dependencies.commitApplication(prepared)}}const adapter=registry[prepared.profile.agent];if(adapter.agent!==prepared.profile.agent){fail2(`adapter registry cross-dispatch detected for ${prepared.profile.agent}`)}await adapter.apply(adapterContext(prepared));return{adapterApplied:true,application:await dependencies.commitApplication(prepared)}}var import_node_crypto4=require("node:crypto");var MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION=1;var MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES=320*1024;var MAX_CORPORATE_CA_ENCODED_BYTES=4*Math.ceil(128*1024/3);var SHA256_RE3=/^[a-f0-9]{64}$/u;var STANDARD_BASE64_RE=/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;var MCP_SHADOW_DIAGNOSTICS_ENV="NEMOCLAW_MCP_SHADOW_DIAGNOSTICS";var MANAGED_STARTUP_APPLICATION_RUNTIME_ENV_KEYS=Object.freeze(MANAGED_STARTUP_PROFILE_DEFERRED_RUNTIME_INPUTS.openclaw.filter(({admission,owner})=>admission==="managed-launch-forwarded"&&owner==="application-environment").map(({input})=>input));function selectManagedStartupApplicationRuntimeEnvironment(environment){const selected={};for(const name of MANAGED_STARTUP_APPLICATION_RUNTIME_ENV_KEYS){const value=environment[name];if(name===MCP_SHADOW_DIAGNOSTICS_ENV){if(value?.trim()==="1")selected[name]="1";continue}if(value!==void 0)selected[name]=value}return Object.freeze(selected)}function fail3(message){throw new Error(`Managed startup root application request is invalid: ${message}`)}function exactAgent(value){if(typeof value==="string"&&MANAGED_STARTUP_AGENTS.includes(value)){return value}return fail3("agent is unsupported")}function createManagedStartupRootApplyRequest(input){const agent=exactAgent(input.agent);if(input.encodedProfile.length===0||input.encodedProfile.length>MANAGED_STARTUP_PROFILE_MAX_ENCODED_BYTES){fail3("encoded profile exceeds its bounded transport")}const profile=decodeManagedStartupProfile(input.encodedProfile);if(profile.agent!==agent){fail3(`profile targets ${profile.agent}, expected ${agent}`)}const corporateCaB64=input.corporateCaB64??null;if(corporateCaB64!==null&&(corporateCaB64.length===0||corporateCaB64.length>MAX_CORPORATE_CA_ENCODED_BYTES||!STANDARD_BASE64_RE.test(corporateCaB64)||Buffer.from(corporateCaB64,"base64").toString("base64")!==corporateCaB64)){fail3("corporate CA is not canonical bounded base64")}if(profile.corporateCa.bundleSha256!==null!==(corporateCaB64!==null)){fail3("corporate CA transport does not match the profile")}if(corporateCaB64!==null&&(0,import_node_crypto4.createHash)("sha256").update(Buffer.from(corporateCaB64,"base64")).digest("hex")!==profile.corporateCa.bundleSha256){fail3("corporate CA does not match the profile digest")}return Object.freeze({schemaVersion:MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION,agent,encodedProfile:input.encodedProfile,profileFingerprint:fingerprintManagedStartupProfile(profile),corporateCaB64})}function serializeManagedStartupRootApplyRequest(request){const normalized=createManagedStartupRootApplyRequest({agent:request.agent,encodedProfile:request.encodedProfile,...request.corporateCaB64===null?{}:{corporateCaB64:request.corporateCaB64}});if(request.schemaVersion!==MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION||request.profileFingerprint!==normalized.profileFingerprint||!SHA256_RE3.test(request.profileFingerprint)){fail3("schema version or profile fingerprint is invalid")}const serialized=`${JSON.stringify({agent:normalized.agent,corporateCaB64:normalized.corporateCaB64,encodedProfile:normalized.encodedProfile,profileFingerprint:normalized.profileFingerprint,schemaVersion:normalized.schemaVersion})} `;if(Buffer.byteLength(serialized,"utf8")>MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES){fail3("serialized request exceeds its bounded transport")}return serialized}function parseManagedStartupRootApplyRequest(text){if(text.length===0||Buffer.byteLength(text,"utf8")>MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES){fail3("serialized request is empty or too large")}let parsed;try{parsed=JSON.parse(text)}catch{fail3("serialized request is not valid JSON")}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail3("serialized request must be an object")}const record=parsed;const expectedKeys=["agent","corporateCaB64","encodedProfile","profileFingerprint","schemaVersion"];if(Object.keys(record).sort().join(",")!==expectedKeys.sort().join(",")||record.schemaVersion!==MANAGED_STARTUP_ROOT_APPLY_SCHEMA_VERSION||typeof record.encodedProfile!=="string"||typeof record.profileFingerprint!=="string"||record.corporateCaB64!==null&&typeof record.corporateCaB64!=="string"){fail3("serialized request has an invalid schema")}const request=createManagedStartupRootApplyRequest({agent:exactAgent(record.agent),encodedProfile:record.encodedProfile,...record.corporateCaB64===null?{}:{corporateCaB64:record.corporateCaB64}});if(record.profileFingerprint!==request.profileFingerprint||!SHA256_RE3.test(record.profileFingerprint)){fail3("profile fingerprint does not match the encoded profile")}if(serializeManagedStartupRootApplyRequest(request)!==text){fail3("serialized request is not canonical")}return request}var import_node_crypto5=require("node:crypto");var import_node_fs2=__toESM(require("node:fs"));var import_node_path2=__toESM(require("node:path"));var TRANSACTION_SCHEMA_VERSION=1;var MAX_TRANSACTION_FILES=128;var MAX_TRANSACTION_FILE_BYTES=8*1024*1024;var MAX_TRANSACTION_TOTAL_BYTES=32*1024*1024;var MAX_MANIFEST_BYTES=256*1024;var MAX_COMMIT_RECEIPT_BYTES=4096;var TRANSACTION_PARENT_DIRECTORY_MODE=493;var TRANSACTION_DIRECTORY_MODE=448;var TRANSACTION_FILE_MODE=256;var ATOMIC_TEMPORARY_FILE_MODE=384;var MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY="/var/lib/nemoclaw/managed-startup-shared-state-transaction-v1";var MANAGED_STARTUP_SHARED_ROLLBACK_RECEIPT_DIRECTORY="/run/nemoclaw/managed-startup-shared-rollback-receipt-v1";var MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY="/var/lib/nemoclaw/managed-startup-shared-state-commit-v1";var MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE="receipt.json";function fail4(message){throw new Error(`Managed startup shared-state transaction failed: ${message}`)}function resolveOptions(options={}){const sandboxRoot=import_node_path2.default.resolve(options.sandboxRoot??"/sandbox");const transactionDirectory=import_node_path2.default.resolve(options.transactionDirectory??MANAGED_STARTUP_SHARED_TRANSACTION_DIRECTORY);const commitReceiptDirectory=import_node_path2.default.resolve(options.commitReceiptDirectory??(options.transactionDirectory?import_node_path2.default.join(import_node_path2.default.dirname(transactionDirectory),import_node_path2.default.basename(MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY)):MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_DIRECTORY));if(transactionDirectory===sandboxRoot||transactionDirectory.startsWith(`${sandboxRoot}${import_node_path2.default.sep}`)||commitReceiptDirectory===sandboxRoot||commitReceiptDirectory.startsWith(`${sandboxRoot}${import_node_path2.default.sep}`)||import_node_path2.default.dirname(commitReceiptDirectory)!==import_node_path2.default.dirname(transactionDirectory)||commitReceiptDirectory===transactionDirectory){fail4("transaction and commit receipts require distinct paths outside sandbox-shared state")}const bootstrapIdentity=options.bootstrapIdentity??null;if(bootstrapIdentity!==null&&!/^[a-f0-9]{64}$/u.test(bootstrapIdentity)){fail4("bootstrap identity must encode 32 lowercase-hex bytes")}return{sandboxRoot,transactionParentDirectory:import_node_path2.default.dirname(transactionDirectory),transactionDirectory,backupDirectory:import_node_path2.default.join(transactionDirectory,"backups"),manifestFile:import_node_path2.default.join(transactionDirectory,"manifest.json"),commitReceiptDirectory,commitReceiptFile:import_node_path2.default.join(commitReceiptDirectory,MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE),trustedUid:options.trustedUid??0,trustedGid:options.trustedGid??0,readOnlyReceipt:options.readOnlyReceipt??false,bootstrapIdentity}}function modeOf2(stat){if(typeof stat.mode==="bigint"){return Number(stat.mode&0o7777n)}return stat.mode&4095}function requireTransactionIdentity(options){const expectedUid=options.readOnlyReceipt?0:options.trustedUid;const expectedGid=options.readOnlyReceipt?0:options.trustedGid;if(process.geteuid?.()!==expectedUid||process.getegid?.()!==expectedGid){fail4("transaction control requires the trusted effective identity")}}function pathExistsNoFollow(target){try{import_node_fs2.default.lstatSync(target);return true}catch(error){if(error.code==="ENOENT")return false;fail4(`could not inspect ${target}`)}}function requireDirectory(target,options,expectedMode=null){let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch{fail4(`required directory is missing: ${target}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`required directory is unsafe: ${target}`)}if(expectedMode!==null&&(stat.uid!==options.trustedUid||stat.gid!==options.trustedGid||modeOf2(stat)!==expectedMode)){fail4(`${target} must be ${options.trustedUid}:${options.trustedGid} mode ${expectedMode.toString(8)}`)}return stat}function requireTransactionBoundaries(options){requireDirectory(options.sandboxRoot,options);requireDirectory(options.transactionParentDirectory,options,TRANSACTION_PARENT_DIRECTORY_MODE)}function sameStableMetadata(left,right){return left.dev===right.dev&&left.ino===right.ino&&left.mode===right.mode&&left.nlink===right.nlink&&left.uid===right.uid&&left.gid===right.gid&&left.size===right.size&&left.mtimeNs===right.mtimeNs&&left.ctimeNs===right.ctimeNs}function readStableFile(target,maxBytes){const noFollow=import_node_fs2.default.constants.O_NOFOLLOW;if(typeof noFollow!=="number")fail4("O_NOFOLLOW is unavailable");let descriptor;try{descriptor=import_node_fs2.default.openSync(target,import_node_fs2.default.constants.O_RDONLY|noFollow)}catch{fail4(`could not safely open ${target}`)}try{const before=import_node_fs2.default.fstatSync(descriptor,{bigint:true});if(!before.isFile()||before.nlink!==1n||before.size<0n||before.size>BigInt(maxBytes)){fail4(`refusing unsafe or oversized transaction file ${target}`)}const bytes=Buffer.alloc(Number(before.size));let offset=0;while(offset!segment||segment==="."||segment==="..")){fail4(`unsafe transaction path ${JSON.stringify(value)}`)}return segments.join("/")}function absoluteTarget(relativePath,options){const safe=safeRelativePath(relativePath);const target=import_node_path2.default.resolve(options.sandboxRoot,safe);if(!target.startsWith(`${options.sandboxRoot}${import_node_path2.default.sep}`)){fail4(`transaction target escapes the sandbox root: ${relativePath}`)}return target}function relativeTarget(target,options){return safeRelativePath(import_node_path2.default.relative(options.sandboxRoot,target))}function validateExistingAncestors(target,expectedAgent,options){const relative=relativeTarget(target,options);const sandboxStat=requireDirectory(options.sandboxRoot,options);const outputRoot=agentRoot(expectedAgent,options.sandboxRoot);if(target!==outputRoot&&!target.startsWith(`${outputRoot}${import_node_path2.default.sep}`)){fail4(`transaction target escapes the ${expectedAgent} state root: ${target}`)}let current=options.sandboxRoot;let expectedDevice=sandboxStat.dev;const segments=relative.split("/").slice(0,-1);for(const segment of segments){current=import_node_path2.default.join(current,segment);let stat;try{stat=import_node_fs2.default.lstatSync(current)}catch(error){if(error.code==="ENOENT")return;fail4(`could not inspect transaction path ancestor ${current}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`transaction path ancestor is unsafe: ${current}`)}if(current===outputRoot&&expectedAgent==="hermes"){expectedDevice=stat.dev}else if(stat.dev!==expectedDevice){fail4(`transaction path crosses a nested filesystem mount: ${current}`)}}}function managedOutputDevice(expectedAgent,options){const sandboxStat=requireDirectory(options.sandboxRoot,options);const outputRoot=agentRoot(expectedAgent,options.sandboxRoot);let stat;try{stat=import_node_fs2.default.lstatSync(outputRoot)}catch(error){if(error.code==="ENOENT")return sandboxStat.dev;fail4(`could not inspect managed output root ${outputRoot}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`managed output root is unsafe: ${outputRoot}`)}if(expectedAgent!=="hermes"&&stat.dev!==sandboxStat.dev){fail4(`managed output root crosses a nested filesystem mount: ${outputRoot}`)}return stat.dev}function agentRoot(agent,sandboxRoot){switch(agent){case"openclaw":return import_node_path2.default.join(sandboxRoot,".openclaw");case"hermes":return import_node_path2.default.join(sandboxRoot,".hermes");case"langchain-deepagents-code":return import_node_path2.default.join(sandboxRoot,".deepagents");case"pi":return import_node_path2.default.join(sandboxRoot,".pi")}}function resolveUnderAgentRoot(root,relativePath){const safe=safeRelativePath(relativePath);const target=import_node_path2.default.resolve(root,safe);if(!target.startsWith(`${root}${import_node_path2.default.sep}`)){fail4(`managed output escapes the agent root: ${relativePath}`)}return target}function renderTarget(root,agent,target){if(agent==="openclaw"&&target==="openclaw.json"){return import_node_path2.default.join(root,"openclaw.json")}const prefix=agent==="openclaw"?"~/.openclaw/":agent==="hermes"?"~/.hermes/":null;if(!prefix||!target.startsWith(prefix)){fail4(`unsupported managed messaging render target ${JSON.stringify(target)}`)}return resolveUnderAgentRoot(root,target.slice(prefix.length))}function managedOutputTargets(profile,options){const root=agentRoot(profile.agent,options.sandboxRoot);const files=new Set;const directories=new Set([root]);switch(profile.agent){case"openclaw":files.add(import_node_path2.default.join(root,"openclaw.json"));files.add(import_node_path2.default.join(root,".config-hash"));break;case"hermes":files.add(import_node_path2.default.join(root,"config.yaml"));files.add(import_node_path2.default.join(root,".env"));files.add(import_node_path2.default.join(root,".config-hash"));break;case"langchain-deepagents-code":files.add(import_node_path2.default.join(root,"config.toml"));directories.add(import_node_path2.default.join(root,".state"));directories.add(import_node_path2.default.join(root,"skills"));break;case"pi":directories.add(import_node_path2.default.join(root,"agent"));files.add(import_node_path2.default.join(root,"agent","models.json"));break}if(profile.messaging.plan!==null){const plan=parseSandboxMessagingPlan(profile.messaging.plan,{agent:profile.agent});if(!plan)fail4("managed messaging plan is invalid");for(const render of selectEnabledMessagingAgentRender(plan)){if(typeof render.target!=="string")continue;files.add(renderTarget(root,profile.agent,render.target))}for(const step of selectEnabledPostAgentInstallBuildFiles(plan)){if(typeof step.value!=="object"||step.value===null){continue}const outputPath=step.value.path;if(typeof outputPath==="string"){files.add(resolveUnderAgentRoot(root,outputPath))}}}for(const file of files){let parent=import_node_path2.default.dirname(file);while(parent!==options.sandboxRoot&&parent.startsWith(`${root}${import_node_path2.default.sep}`)){directories.add(parent);if(parent===root)break;parent=import_node_path2.default.dirname(parent)}}return{files:[...files].sort(),directories:[...directories].sort((left,right)=>left.split(import_node_path2.default.sep).length-right.split(import_node_path2.default.sep).length)}}function snapshotFile(target,index,expectedAgent,options){validateExistingAncestors(target,expectedAgent,options);let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT"){return{receipt:{path:relativeTarget(target,options),state:"absent"},bytes:null}}fail4(`could not inspect managed output ${target}`)}if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1){fail4(`managed output is not a safe regular file: ${target}`)}if(stat.dev!==managedOutputDevice(expectedAgent,options)){fail4(`managed output crosses a nested filesystem mount: ${target}`)}const stable=readStableFile(target,MAX_TRANSACTION_FILE_BYTES);const size=Number(stable.stat.size);const backup=`${String(index).padStart(3,"0")}.bin`;return{receipt:{path:relativeTarget(target,options),state:"file",backup,sha256:(0,import_node_crypto5.createHash)("sha256").update(stable.bytes).digest("hex"),size,uid:Number(stable.stat.uid),gid:Number(stable.stat.gid),mode:Number(stable.stat.mode&0o7777n)},bytes:stable.bytes}}function snapshotDirectory(target,expectedAgent,options){validateExistingAncestors(import_node_path2.default.join(target,".receipt"),expectedAgent,options);let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT"){return{path:relativeTarget(target,options),state:"absent"}}fail4(`could not inspect managed output directory ${target}`)}if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`managed output directory is unsafe: ${target}`)}if(stat.dev!==managedOutputDevice(expectedAgent,options)){fail4(`managed output directory crosses a nested filesystem mount: ${target}`)}return{path:relativeTarget(target,options),state:"directory",uid:stat.uid,gid:stat.gid,mode:modeOf2(stat)}}function atomicWriteTrustedFile(target,contents,mode,uid,gid){const parent=import_node_path2.default.dirname(target);const temporary=import_node_path2.default.join(parent,`.${import_node_path2.default.basename(target)}.${(0,import_node_crypto5.randomBytes)(12).toString("hex")}`);let descriptor;try{descriptor=import_node_fs2.default.openSync(temporary,import_node_fs2.default.constants.O_CREAT|import_node_fs2.default.constants.O_EXCL|import_node_fs2.default.constants.O_WRONLY|import_node_fs2.default.constants.O_NOFOLLOW,384);import_node_fs2.default.writeFileSync(descriptor,contents);import_node_fs2.default.fchownSync(descriptor,uid,gid);import_node_fs2.default.fchmodSync(descriptor,mode);import_node_fs2.default.fsyncSync(descriptor);import_node_fs2.default.closeSync(descriptor);descriptor=void 0;import_node_fs2.default.renameSync(temporary,target)}catch(error){if(descriptor!==void 0)import_node_fs2.default.closeSync(descriptor);try{import_node_fs2.default.unlinkSync(temporary)}catch{}fail4(`could not atomically write ${target}: ${error.message}`)}}function fsyncDirectory(directory){const descriptor=import_node_fs2.default.openSync(directory,import_node_fs2.default.constants.O_RDONLY);try{import_node_fs2.default.fsyncSync(descriptor)}finally{import_node_fs2.default.closeSync(descriptor)}}function canonicalManifest(manifest){return`${JSON.stringify(manifest,null,2)} `}function canonicalLegacyManifest(manifest){return`${JSON.stringify({schemaVersion:manifest.schemaVersion,agent:manifest.agent,profileFingerprint:manifest.profileFingerprint,files:manifest.files,directories:manifest.directories},null,2)} `}function canonicalCommitReceipt(receipt){return`${JSON.stringify(receipt,null,2)} -`}function requireExactKeys(record,keys){if(Object.keys(record).sort().join(",")!==[...keys].sort().join(",")){fail4("transaction manifest contains unexpected fields")}}function parseCommitReceipt(text){let parsed;try{parsed=JSON.parse(text)}catch{fail4("commit receipt is not valid JSON")}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail4("commit receipt must be an object")}const record=parsed;requireExactKeys(record,["agent","bootstrapIdentity","profileFingerprint","schemaVersion"]);if(record.schemaVersion!==TRANSACTION_SCHEMA_VERSION||!MANAGED_STARTUP_AGENTS.includes(String(record.agent))||typeof record.profileFingerprint!=="string"||!/^[a-f0-9]{64}$/u.test(record.profileFingerprint)||typeof record.bootstrapIdentity!=="string"||!/^[a-f0-9]{64}$/u.test(record.bootstrapIdentity)){fail4("commit receipt has an invalid envelope")}const receipt={schemaVersion:TRANSACTION_SCHEMA_VERSION,agent:record.agent,profileFingerprint:record.profileFingerprint,bootstrapIdentity:record.bootstrapIdentity};if(canonicalCommitReceipt(receipt)!==text){fail4("commit receipt is not canonical")}return receipt}function safeMetadata(value){return Number.isSafeInteger(value)&&value>=0}function parseManifest(text){let parsed;try{parsed=JSON.parse(text)}catch{fail4("transaction manifest is not valid JSON")}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail4("transaction manifest must be an object")}const record=parsed;const hasBootstrapIdentity=Object.hasOwn(record,"bootstrapIdentity");requireExactKeys(record,hasBootstrapIdentity?["agent","bootstrapIdentity","directories","files","profileFingerprint","schemaVersion"]:["agent","directories","files","profileFingerprint","schemaVersion"]);const bootstrapIdentity=hasBootstrapIdentity?record.bootstrapIdentity:null;if(record.schemaVersion!==TRANSACTION_SCHEMA_VERSION||!MANAGED_STARTUP_AGENTS.includes(String(record.agent))||typeof record.profileFingerprint!=="string"||!/^[a-f0-9]{64}$/u.test(record.profileFingerprint)||!(bootstrapIdentity===null||typeof bootstrapIdentity==="string"&&/^[a-f0-9]{64}$/u.test(bootstrapIdentity))||!Array.isArray(record.files)||!Array.isArray(record.directories)||record.files.length>MAX_TRANSACTION_FILES||record.directories.length>MAX_TRANSACTION_FILES*4){fail4("transaction manifest has an invalid envelope")}const files=record.files.map(value=>{if(typeof value!=="object"||value===null||Array.isArray(value)){return fail4("transaction file receipt must be an object")}const receipt=value;if(typeof receipt.path!=="string"){return fail4("transaction file receipt path must be a string")}const receiptPath=safeRelativePath(receipt.path);if(receipt.state==="absent"){requireExactKeys(receipt,["path","state"]);return{path:receiptPath,state:"absent"}}requireExactKeys(receipt,["backup","gid","mode","path","sha256","size","state","uid"]);if(receipt.state!=="file"||typeof receipt.backup!=="string"||!/^[0-9]{3}\.bin$/u.test(receipt.backup)||typeof receipt.sha256!=="string"||!/^[a-f0-9]{64}$/u.test(receipt.sha256)||!safeMetadata(receipt.size)||receipt.size>MAX_TRANSACTION_FILE_BYTES||!safeMetadata(receipt.uid)||!safeMetadata(receipt.gid)||!safeMetadata(receipt.mode)||receipt.mode>4095){return fail4("transaction file receipt is invalid")}return{path:receiptPath,state:"file",backup:receipt.backup,sha256:receipt.sha256,size:receipt.size,uid:receipt.uid,gid:receipt.gid,mode:receipt.mode}});const directories=record.directories.map(value=>{if(typeof value!=="object"||value===null||Array.isArray(value)){return fail4("transaction directory receipt must be an object")}const receipt=value;if(typeof receipt.path!=="string"){return fail4("transaction directory receipt path must be a string")}const receiptPath=safeRelativePath(receipt.path);if(receipt.state==="absent"){requireExactKeys(receipt,["path","state"]);return{path:receiptPath,state:"absent"}}requireExactKeys(receipt,["gid","mode","path","state","uid"]);if(receipt.state!=="directory"||!safeMetadata(receipt.uid)||!safeMetadata(receipt.gid)||!safeMetadata(receipt.mode)||receipt.mode>4095){return fail4("transaction directory receipt is invalid")}return{path:receiptPath,state:"directory",uid:receipt.uid,gid:receipt.gid,mode:receipt.mode}});const filePaths=files.map(receipt=>receipt.path);const directoryPaths=directories.map(receipt=>receipt.path);const backupNames=files.filter(receipt=>receipt.state==="file").map(receipt=>receipt.backup);if(new Set(filePaths).size!==filePaths.length||new Set(directoryPaths).size!==directoryPaths.length||new Set(backupNames).size!==backupNames.length){fail4("transaction manifest contains duplicate receipts")}const manifest={schemaVersion:TRANSACTION_SCHEMA_VERSION,agent:record.agent,profileFingerprint:record.profileFingerprint,bootstrapIdentity,files,directories};const canonical=hasBootstrapIdentity?canonicalManifest(manifest):canonicalLegacyManifest(manifest);if(canonical!==text){fail4("transaction manifest is not canonical")}return manifest}function requireTrustedTransactionPath(target,mode,options){const stat=import_node_fs2.default.lstatSync(target);if(stat.isSymbolicLink()||(mode===TRANSACTION_DIRECTORY_MODE?!stat.isDirectory():!stat.isFile())||!options.readOnlyReceipt&&(stat.uid!==options.trustedUid||stat.gid!==options.trustedGid)||modeOf2(stat)!==mode){fail4(`transaction artifact has unsafe metadata: ${target}`)}}function requireReadOnlyReceiptMount(target,options){if(!options.readOnlyReceipt)return;const probe=import_node_path2.default.join(target,".nemoclaw-write-probe");let descriptor;try{descriptor=import_node_fs2.default.openSync(probe,import_node_fs2.default.constants.O_CREAT|import_node_fs2.default.constants.O_EXCL|import_node_fs2.default.constants.O_WRONLY|import_node_fs2.default.constants.O_NOFOLLOW,384);import_node_fs2.default.closeSync(descriptor);descriptor=void 0;import_node_fs2.default.unlinkSync(probe)}catch(error){if(descriptor!==void 0)import_node_fs2.default.closeSync(descriptor);if(error.code==="EROFS")return;fail4("copied receipt must be mounted on a read-only filesystem")}fail4("copied receipt mount is writable")}function loadManifest(options){requireTransactionBoundaries(options);if(!pathExistsNoFollow(options.transactionDirectory))return null;requireTrustedTransactionPath(options.transactionDirectory,TRANSACTION_DIRECTORY_MODE,options);requireReadOnlyReceiptMount(options.transactionDirectory,options);requireTrustedTransactionPath(options.backupDirectory,TRANSACTION_DIRECTORY_MODE,options);requireTrustedTransactionPath(options.manifestFile,TRANSACTION_FILE_MODE,options);const stable=readStableFile(options.manifestFile,MAX_MANIFEST_BYTES);if(!options.readOnlyReceipt&&(Number(stable.stat.uid)!==options.trustedUid||Number(stable.stat.gid)!==options.trustedGid)||Number(stable.stat.mode&0o7777n)!==TRANSACTION_FILE_MODE){fail4("transaction manifest ownership changed while it was read")}return parseManifest(stable.bytes.toString("utf8"))}function transactionOptionsAt(options,transactionDirectory){return{...options,transactionDirectory,backupDirectory:import_node_path2.default.join(transactionDirectory,"backups"),manifestFile:import_node_path2.default.join(transactionDirectory,"manifest.json")}}function loadCommitReceipt(options){requireTransactionBoundaries(options);if(!pathExistsNoFollow(options.commitReceiptDirectory))return null;requireTrustedTransactionPath(options.commitReceiptDirectory,TRANSACTION_DIRECTORY_MODE,options);if(pathExistsNoFollow(options.commitReceiptFile)){requireReadOnlyReceiptMount(options.commitReceiptDirectory,options);requireTrustedTransactionPath(options.commitReceiptFile,TRANSACTION_FILE_MODE,options);const stable=readStableFile(options.commitReceiptFile,MAX_COMMIT_RECEIPT_BYTES);if(!options.readOnlyReceipt&&(Number(stable.stat.uid)!==options.trustedUid||Number(stable.stat.gid)!==options.trustedGid)||Number(stable.stat.mode&0o7777n)!==TRANSACTION_FILE_MODE){fail4("commit receipt ownership changed while it was read")}return{receipt:parseCommitReceipt(stable.bytes.toString("utf8")),compact:true}}const stagedOptions=transactionOptionsAt(options,options.commitReceiptDirectory);const staged=loadManifest(stagedOptions);if(!staged||staged.bootstrapIdentity===null){fail4("durable commit staging receipt is incomplete")}verifyAllBackups(staged.files,stagedOptions);return{receipt:{schemaVersion:TRANSACTION_SCHEMA_VERSION,agent:staged.agent,profileFingerprint:staged.profileFingerprint,bootstrapIdentity:staged.bootstrapIdentity},compact:false}}function verifyBackup(receipt,options){const backupPath=import_node_path2.default.join(options.backupDirectory,receipt.backup);requireTrustedTransactionPath(backupPath,TRANSACTION_FILE_MODE,options);const stable=readStableFile(backupPath,MAX_TRANSACTION_FILE_BYTES);const digest=(0,import_node_crypto5.createHash)("sha256").update(stable.bytes).digest("hex");if(stable.bytes.length!==receipt.size||digest!==receipt.sha256){fail4(`transaction backup does not match its receipt: ${receipt.path}`)}return stable.bytes}function verifyAllBackups(receipts,options){const backups=new Map;for(const receipt of receipts){if(receipt.state==="file"){backups.set(receipt.path,verifyBackup(receipt,options))}}return backups}function fileMatchesReceipt(target,receipt){let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return false;fail4(`could not inspect managed output ${target}`)}if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1)return false;const stable=readStableFile(target,MAX_TRANSACTION_FILE_BYTES);return stable.bytes.length===receipt.size&&(0,import_node_crypto5.createHash)("sha256").update(stable.bytes).digest("hex")===receipt.sha256&&Number(stable.stat.uid)===receipt.uid&&Number(stable.stat.gid)===receipt.gid&&Number(stable.stat.mode&0o7777n)===receipt.mode}function directoryMatchesReceipt(target,receipt){let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return false;fail4(`could not inspect managed output directory ${target}`)}return!stat.isSymbolicLink()&&stat.isDirectory()&&stat.uid===receipt.uid&&stat.gid===receipt.gid&&modeOf2(stat)===receipt.mode}function removeTransactionDirectory(options){requireTrustedTransactionPath(options.transactionDirectory,TRANSACTION_DIRECTORY_MODE,options);import_node_fs2.default.rmSync(options.transactionDirectory,{force:false,recursive:true});fsyncDirectory(options.transactionParentDirectory);if(pathExistsNoFollow(options.transactionDirectory)){fail4("transaction directory remained after cleanup")}}function assertCommitReceiptMatches(receipt,expected){if(receipt.agent!==expected.agent||expected.profileFingerprint!==void 0&&receipt.profileFingerprint!==expected.profileFingerprint||receipt.bootstrapIdentity!==expected.bootstrapIdentity){fail4("durable commit receipt belongs to a different bootstrap attempt")}}function loadCommitStagingManifest(options){if(!pathExistsNoFollow(options.manifestFile))return null;requireTrustedTransactionPath(options.manifestFile,TRANSACTION_FILE_MODE,options);const stable=readStableFile(options.manifestFile,MAX_MANIFEST_BYTES);if(Number(stable.stat.uid)!==options.trustedUid||Number(stable.stat.gid)!==options.trustedGid||Number(stable.stat.mode&0o7777n)!==TRANSACTION_FILE_MODE){fail4("durable commit staging manifest ownership changed while it was read")}return parseManifest(stable.bytes.toString("utf8"))}function retireInterruptedCommitReceiptWrites(receipt,options){const temporaryPattern=new RegExp(`^\\.${MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE.replace(".","\\.")}\\.[a-f0-9]{24}$`,"u");for(const entry of import_node_fs2.default.readdirSync(options.commitReceiptDirectory)){if(!temporaryPattern.test(entry))continue;const target=import_node_path2.default.join(options.commitReceiptDirectory,entry);const stat=import_node_fs2.default.lstatSync(target);if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1||stat.uid!==options.trustedUid||stat.gid!==options.trustedGid||![ATOMIC_TEMPORARY_FILE_MODE,TRANSACTION_FILE_MODE].includes(modeOf2(stat))){fail4("interrupted durable commit receipt write has unsafe metadata")}const stable=readStableFile(target,MAX_COMMIT_RECEIPT_BYTES);const mode=Number(stable.stat.mode&0o7777n);if(Number(stable.stat.uid)!==options.trustedUid||Number(stable.stat.gid)!==options.trustedGid||![ATOMIC_TEMPORARY_FILE_MODE,TRANSACTION_FILE_MODE].includes(mode)){fail4("interrupted durable commit receipt write changed during verification")}if(stable.bytes.length>0){let interruptedReceipt=null;try{interruptedReceipt=parseCommitReceipt(stable.bytes.toString("utf8"))}catch{}if(interruptedReceipt)assertCommitReceiptMatches(interruptedReceipt,receipt)}import_node_fs2.default.unlinkSync(target);fsyncDirectory(options.commitReceiptDirectory)}}function compactDurableCommitReceipt(state,options){if(!state.compact){atomicWriteTrustedFile(options.commitReceiptFile,canonicalCommitReceipt(state.receipt),TRANSACTION_FILE_MODE,options.trustedUid,options.trustedGid);fsyncDirectory(options.commitReceiptDirectory)}retireInterruptedCommitReceiptWrites(state.receipt,options);const stagedOptions=transactionOptionsAt(options,options.commitReceiptDirectory);const manifestExists=pathExistsNoFollow(stagedOptions.manifestFile);const backupsExist=pathExistsNoFollow(stagedOptions.backupDirectory);const unexpectedBeforeCleanup=import_node_fs2.default.readdirSync(options.commitReceiptDirectory).filter(entry=>![MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE,import_node_path2.default.basename(stagedOptions.backupDirectory),import_node_path2.default.basename(stagedOptions.manifestFile)].includes(entry));if(unexpectedBeforeCleanup.length!==0){fail4("durable commit receipt directory contains unexpected artifacts")}if(manifestExists){const staged=loadCommitStagingManifest(stagedOptions);if(!staged||staged.bootstrapIdentity===null){fail4("durable commit staging receipt disappeared during cleanup")}assertCommitReceiptMatches(state.receipt,{agent:staged.agent,profileFingerprint:staged.profileFingerprint,bootstrapIdentity:staged.bootstrapIdentity})}if(backupsExist){requireTrustedTransactionPath(stagedOptions.backupDirectory,TRANSACTION_DIRECTORY_MODE,options);import_node_fs2.default.rmSync(stagedOptions.backupDirectory,{force:false,recursive:true});fsyncDirectory(options.commitReceiptDirectory)}if(manifestExists){requireTrustedTransactionPath(stagedOptions.manifestFile,TRANSACTION_FILE_MODE,options);import_node_fs2.default.unlinkSync(stagedOptions.manifestFile);fsyncDirectory(options.commitReceiptDirectory)}const unexpected=import_node_fs2.default.readdirSync(options.commitReceiptDirectory).filter(entry=>entry!==MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE);if(unexpected.length!==0){fail4("durable commit receipt directory contains unexpected artifacts")}const verified=loadCommitReceipt(options);if(!verified?.compact)fail4("durable commit receipt did not compact successfully");assertCommitReceiptMatches(verified.receipt,state.receipt)}function beginManagedStartupSharedStateTransaction(profile,inputOptions={}){const options=resolveOptions(inputOptions);requireTransactionIdentity(options);if(options.readOnlyReceipt){fail4("cannot begin a transaction from a read-only rollback receipt")}requireTransactionBoundaries(options);const profileFingerprint=fingerprintManagedStartupProfile(profile);const committed=loadCommitReceipt(options);if(committed){if(options.bootstrapIdentity===null){fail4("a durable managed bootstrap commit receipt already exists")}assertCommitReceiptMatches(committed.receipt,{agent:profile.agent,profileFingerprint,bootstrapIdentity:options.bootstrapIdentity});fail4("this managed bootstrap attempt is already durably committed")}const pending=loadManifest(options);if(pending){if(pending.agent!==profile.agent||pending.profileFingerprint!==profileFingerprint||pending.bootstrapIdentity!==options.bootstrapIdentity){fail4("a pending managed startup transaction belongs to a different agent, profile fingerprint, or bootstrap attempt")}verifyAllBackups(pending.files,options);return false}const targets=managedOutputTargets(profile,options);if(targets.files.length>MAX_TRANSACTION_FILES){fail4("managed startup transaction has too many file targets")}const snapshots=targets.files.map((target,index)=>snapshotFile(target,index,profile.agent,options));const totalBytes=snapshots.reduce((sum,snapshot)=>sum+(snapshot.bytes?.length??0),0);if(totalBytes>MAX_TRANSACTION_TOTAL_BYTES){fail4("managed startup transaction backup exceeds the total size limit")}const directories=targets.directories.map(target=>snapshotDirectory(target,profile.agent,options));const manifest={schemaVersion:TRANSACTION_SCHEMA_VERSION,agent:profile.agent,profileFingerprint,bootstrapIdentity:options.bootstrapIdentity,files:snapshots.map(({receipt})=>receipt),directories};let createdTransactionIdentity;try{import_node_fs2.default.mkdirSync(options.transactionDirectory,{mode:TRANSACTION_DIRECTORY_MODE});const created=import_node_fs2.default.lstatSync(options.transactionDirectory,{bigint:true});if(!created.isDirectory()||created.isSymbolicLink()){fail4("new transaction path is not a directory")}createdTransactionIdentity={dev:created.dev,ino:created.ino,uid:created.uid,gid:created.gid};import_node_fs2.default.chownSync(options.transactionDirectory,options.trustedUid,options.trustedGid);import_node_fs2.default.chmodSync(options.transactionDirectory,TRANSACTION_DIRECTORY_MODE);fsyncDirectory(options.transactionParentDirectory);import_node_fs2.default.mkdirSync(options.backupDirectory,{mode:TRANSACTION_DIRECTORY_MODE});import_node_fs2.default.chownSync(options.backupDirectory,options.trustedUid,options.trustedGid);import_node_fs2.default.chmodSync(options.backupDirectory,TRANSACTION_DIRECTORY_MODE);fsyncDirectory(options.transactionDirectory);for(const snapshot of snapshots){if(snapshot.receipt.state!=="file"||snapshot.bytes===null)continue;atomicWriteTrustedFile(import_node_path2.default.join(options.backupDirectory,snapshot.receipt.backup),snapshot.bytes,TRANSACTION_FILE_MODE,options.trustedUid,options.trustedGid)}fsyncDirectory(options.backupDirectory);atomicWriteTrustedFile(options.manifestFile,canonicalManifest(manifest),TRANSACTION_FILE_MODE,options.trustedUid,options.trustedGid);fsyncDirectory(options.transactionDirectory);loadManifest(options)}catch(error){try{if(createdTransactionIdentity&&pathExistsNoFollow(options.transactionDirectory)){const current=import_node_fs2.default.lstatSync(options.transactionDirectory,{bigint:true});if(!current.isSymbolicLink()&¤t.isDirectory()&¤t.dev===createdTransactionIdentity.dev&¤t.ino===createdTransactionIdentity.ino&¤t.uid===createdTransactionIdentity.uid&¤t.gid===createdTransactionIdentity.gid){import_node_fs2.default.chmodSync(options.transactionDirectory,TRANSACTION_DIRECTORY_MODE);import_node_fs2.default.chownSync(options.transactionDirectory,options.trustedUid,options.trustedGid)}requireTrustedTransactionPath(options.transactionDirectory,TRANSACTION_DIRECTORY_MODE,options);import_node_fs2.default.rmSync(options.transactionDirectory,{force:true,recursive:true})}}catch{}throw error}return true}function ensureOriginalDirectories(receipts,expectedAgent,options){for(const receipt of receipts){if(receipt.state!=="directory")continue;const target=absoluteTarget(receipt.path,options);validateExistingAncestors(import_node_path2.default.join(target,".restore"),expectedAgent,options);let stat=null;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code!=="ENOENT"){fail4(`could not inspect restore directory ${target}`)}}if(stat&&(stat.isSymbolicLink()||!stat.isDirectory())){fail4(`restore directory is unsafe: ${target}`)}if(stat&&directoryMatchesReceipt(target,receipt))continue;if(!stat)import_node_fs2.default.mkdirSync(target,{mode:receipt.mode});import_node_fs2.default.chownSync(target,receipt.uid,receipt.gid);import_node_fs2.default.chmodSync(target,receipt.mode)}}function restoreFiles(receipts,backups,expectedAgent,options){for(const receipt of receipts){const target=absoluteTarget(receipt.path,options);validateExistingAncestors(target,expectedAgent,options);if(receipt.state==="absent"){let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")continue;fail4(`could not inspect new managed output ${target}`)}if(stat.isDirectory()){fail4(`new managed output unexpectedly became a directory: ${target}`)}import_node_fs2.default.unlinkSync(target);continue}if(fileMatchesReceipt(target,receipt))continue;const bytes=backups.get(receipt.path);if(!bytes)fail4(`verified transaction backup is missing: ${receipt.path}`);let current=null;try{current=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code!=="ENOENT"){fail4(`could not inspect managed output before restore: ${target}`)}}if(current?.isDirectory()){fail4(`managed output unexpectedly became a directory: ${target}`)}atomicWriteTrustedFile(target,bytes,receipt.mode,receipt.uid,receipt.gid)}}function restoreDirectoryMetadata(receipts,options){for(const receipt of[...receipts].reverse()){const target=absoluteTarget(receipt.path,options);if(receipt.state==="absent"){try{import_node_fs2.default.rmdirSync(target)}catch(error){if(error.code==="ENOENT")continue;fail4(`could not remove newly created managed directory ${target}`)}continue}if(directoryMatchesReceipt(target,receipt))continue;const stat=import_node_fs2.default.lstatSync(target);if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`managed directory changed type during restore: ${target}`)}import_node_fs2.default.chownSync(target,receipt.uid,receipt.gid);import_node_fs2.default.chmodSync(target,receipt.mode)}}function verifyRestoration(manifest,options){for(const receipt of manifest.files){const target=absoluteTarget(receipt.path,options);if(receipt.state==="absent"){if(pathExistsNoFollow(target)){fail4(`new managed output remained after rollback: ${target}`)}continue}const stable=readStableFile(target,MAX_TRANSACTION_FILE_BYTES);if(stable.bytes.length!==receipt.size||(0,import_node_crypto5.createHash)("sha256").update(stable.bytes).digest("hex")!==receipt.sha256||Number(stable.stat.uid)!==receipt.uid||Number(stable.stat.gid)!==receipt.gid||Number(stable.stat.mode&0o7777n)!==receipt.mode){fail4(`managed output was not restored exactly: ${target}`)}}for(const receipt of manifest.directories){const target=absoluteTarget(receipt.path,options);if(receipt.state==="absent"){if(pathExistsNoFollow(target)){fail4(`new managed directory remained after rollback: ${target}`)}continue}const stat=import_node_fs2.default.lstatSync(target);if(stat.isSymbolicLink()||!stat.isDirectory()||stat.uid!==receipt.uid||stat.gid!==receipt.gid||modeOf2(stat)!==receipt.mode){fail4(`managed directory metadata was not restored exactly: ${target}`)}}}function rollbackManagedStartupSharedStateTransaction(expectedAgent,inputOptions={}){const options=resolveOptions(inputOptions);requireTransactionIdentity(options);const committed=loadCommitReceipt(options);if(committed){if(options.bootstrapIdentity===null){fail4("shared state is already durably committed")}assertCommitReceiptMatches(committed.receipt,{agent:expectedAgent,bootstrapIdentity:options.bootstrapIdentity});fail4("shared state is already durably committed and cannot be rolled back")}const manifest=loadManifest(options);if(!manifest)return false;if(manifest.agent!==expectedAgent){fail4(`pending transaction targets ${manifest.agent}, expected ${expectedAgent}`)}if(manifest.bootstrapIdentity!==options.bootstrapIdentity){fail4("pending transaction belongs to a different bootstrap attempt")}const backups=verifyAllBackups(manifest.files,options);ensureOriginalDirectories(manifest.directories,expectedAgent,options);restoreFiles(manifest.files,backups,expectedAgent,options);restoreDirectoryMetadata(manifest.directories,options);verifyRestoration(manifest,options);if(!options.readOnlyReceipt){removeTransactionDirectory(options)}return true}function commitManagedStartupSharedStateTransaction(expectedAgent,inputOptions={}){const options=resolveOptions(inputOptions);requireTransactionIdentity(options);if(options.readOnlyReceipt){fail4("cannot commit a read-only rollback receipt")}const committed=loadCommitReceipt(options);if(committed){if(options.bootstrapIdentity===null){fail4("durable commit receipt is missing its expected bootstrap identity")}assertCommitReceiptMatches(committed.receipt,{agent:expectedAgent,bootstrapIdentity:options.bootstrapIdentity});compactDurableCommitReceipt(committed,options);return true}const manifest=loadManifest(options);if(!manifest)return false;if(manifest.agent!==expectedAgent){fail4(`pending transaction targets ${manifest.agent}, expected ${expectedAgent}`)}if(manifest.bootstrapIdentity!==options.bootstrapIdentity){fail4("pending transaction belongs to a different bootstrap attempt")}if(manifest.bootstrapIdentity===null){removeTransactionDirectory(options);return true}verifyAllBackups(manifest.files,options);if(pathExistsNoFollow(options.commitReceiptDirectory)){fail4("durable commit receipt path appeared before transaction commit")}try{import_node_fs2.default.renameSync(options.transactionDirectory,options.commitReceiptDirectory);fsyncDirectory(options.transactionParentDirectory)}catch(error){fail4(`could not atomically establish durable commit state: ${error.message}`)}const renamed=loadCommitReceipt(options);if(!renamed)fail4("durable commit state disappeared after atomic rename");assertCommitReceiptMatches(renamed.receipt,{agent:expectedAgent,profileFingerprint:manifest.profileFingerprint,bootstrapIdentity:manifest.bootstrapIdentity});compactDurableCommitReceipt(renamed,options);return true}function clearManagedStartupSharedStateCommitReceipt(expectedAgent,inputOptions={}){const options=resolveOptions(inputOptions);requireTransactionIdentity(options);if(options.readOnlyReceipt){fail4("cannot clear a durable commit from a read-only receipt")}if(options.bootstrapIdentity===null){fail4("durable commit cleanup requires its bootstrap identity")}const committed=loadCommitReceipt(options);if(!committed)return false;assertCommitReceiptMatches(committed.receipt,{agent:expectedAgent,bootstrapIdentity:options.bootstrapIdentity});compactDurableCommitReceipt(committed,options);requireTrustedTransactionPath(options.commitReceiptDirectory,TRANSACTION_DIRECTORY_MODE,options);requireTrustedTransactionPath(options.commitReceiptFile,TRANSACTION_FILE_MODE,options);const entries=import_node_fs2.default.readdirSync(options.commitReceiptDirectory);if(entries.length!==1||entries[0]!==MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE){fail4("durable commit receipt directory contains unexpected artifacts")}import_node_fs2.default.rmSync(options.commitReceiptDirectory,{force:false,recursive:true});fsyncDirectory(options.transactionParentDirectory);if(pathExistsNoFollow(options.commitReceiptDirectory)){fail4("durable commit receipt remained after cleanup")}return true}function getManagedStartupSharedStateTransactionStatus(expected,inputOptions={}){const options=resolveOptions({...inputOptions,bootstrapIdentity:expected.bootstrapIdentity});requireTransactionIdentity(options);const manifest=loadManifest(options);if(manifest){if(manifest.agent!==expected.agent||manifest.profileFingerprint!==expected.profileFingerprint||manifest.bootstrapIdentity!==expected.bootstrapIdentity){fail4("pending transaction does not match the expected agent, profile fingerprint, or bootstrap identity")}verifyAllBackups(manifest.files,options);return"pending"}const committed=loadCommitReceipt(options);if(!committed)return"none";assertCommitReceiptMatches(committed.receipt,expected);return"committed"}var MANAGED_STARTUP_PROFILE_ENV="NEMOCLAW_STARTUP_PROFILE_B64";var MANAGED_STARTUP_CA_ENV="NEMOCLAW_CORPORATE_CA_B64";var MANAGED_STARTUP_RUNTIME_ENV_FILE="/run/nemoclaw/managed-startup-runtime.env";var MANAGED_STARTUP_RUNTIME_EXECUTABLE="/usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs";var MANAGED_STARTUP_MERGED_CA_FILE="/run/nemoclaw/managed-startup-ca-bundle.pem";var MANAGED_STARTUP_COMPLETION_FILE="/run/nemoclaw/managed-startup-complete.json";var MANAGED_STARTUP_CORPORATE_CA_FILE="/usr/local/share/nemoclaw/corporate-ca.pem";var MESSAGING_RUNTIME_PLAN_FILE="/usr/local/share/nemoclaw/messaging-runtime-plan.json";var ROOT_STATE_PARENT="/var/lib/nemoclaw";var ROOT_RUNTIME_DIRECTORY="/run/nemoclaw";var ROOT_OWNED_DIRECTORY_MODE=493;var MAX_TRUST_BUNDLE_BYTES=4*1024*1024;var HERMES_MANAGED_CONFIG_FILES=["/sandbox/.hermes/config.yaml","/sandbox/.hermes/.env"];var HERMES_GENERATED_MANAGED_POLICY_FILE="/sandbox/.hermes/managed-policy.json";var HERMES_INSTALLED_MANAGED_POLICY_FILE="/usr/local/share/nemoclaw/hermes-managed-policy.json";var MAX_HERMES_MANAGED_POLICY_BYTES=4*1024*1024;var FIXED_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";var SHA256_RE4=/^[a-f0-9]{64}$/u;var MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION=1;var MAX_MANAGED_STARTUP_COMPLETION_BYTES=4096;var MAX_MANAGED_STARTUP_RUNTIME_ENVIRONMENT_BYTES=512*1024;var ManagedStartupImageActionPlanError=class extends Error{constructor(message){super(`Cannot build managed startup image action plan: ${message}`);this.name="ManagedStartupImageActionPlanError"}};var ManagedStartupImageRuntimeError=class extends Error{constructor(message){super(`Managed startup image application failed: ${message}`);this.name="ManagedStartupImageRuntimeError"}};function failActionPlan(message){throw new ManagedStartupImageActionPlanError(message)}function exactActionPlanAgent(value){if(MANAGED_STARTUP_AGENTS.includes(value)){return value}return failActionPlan(`unsupported agent ${JSON.stringify(value)}`)}function fail5(message){throw new ManagedStartupImageRuntimeError(message)}function validateManagedStartupApplicationRuntimePlan(plan){if(typeof plan!=="object"||plan===null){return fail5("application runtime plan must be an object")}const exportEnvironment=plan.exportEnvironment;const unsetEnvironment=plan.unsetEnvironment;if(typeof exportEnvironment!=="object"||exportEnvironment===null||Array.isArray(exportEnvironment)||!Array.isArray(unsetEnvironment)){return fail5("application runtime plan must contain exports and unsets")}const exports2={};for(const[name,value]of Object.entries(exportEnvironment)){if(!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)){return fail5(`invalid application runtime environment key ${JSON.stringify(name)}`)}if(typeof value!=="string"||value.includes("\0")||/[\r\n]/u.test(value)){return fail5(`application runtime environment value for ${name} must be single-line text`)}exports2[name]=value}const unsets=new Set;for(const name of unsetEnvironment){if(typeof name!=="string"||!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)){return fail5(`invalid application runtime unset ${JSON.stringify(name)}`)}if(unsets.has(name)){return fail5(`duplicate application runtime unset ${name}`)}if(Object.hasOwn(exports2,name)){return fail5(`application runtime cannot both export and unset ${name}`)}unsets.add(name)}return Object.freeze({exportEnvironment:Object.freeze(Object.fromEntries(Object.entries(exports2).sort(([left],[right])=>left.localeCompare(right)))),unsetEnvironment:Object.freeze([...unsets].sort())})}function applyManagedStartupCommandEnvironmentPlan(environment,plan){const validated=validateManagedStartupApplicationRuntimePlan(plan);const applied={...environment};for(const name of[...Object.keys(validated.exportEnvironment),...validated.unsetEnvironment]){delete applied[name]}return applied}function exactAgent2(value){if(MANAGED_STARTUP_AGENTS.includes(value)){return value}return fail5(`unsupported agent ${JSON.stringify(value)}`)}function managedTransactionProfile(expectedAgentInput,env=process.env){requireRoot();const expectedAgent=exactAgent2(expectedAgentInput);if(env.NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION!=="1"){fail5("shared-state transactions require a complete managed image")}const encodedProfile=env[MANAGED_STARTUP_PROFILE_ENV];if(!encodedProfile)fail5(`${MANAGED_STARTUP_PROFILE_ENV} is required`);const profile=decodeManagedStartupProfile(encodedProfile);if(profile.agent!==expectedAgent){fail5(`shared-state transaction profile targets ${profile.agent}, expected ${expectedAgent}`)}return profile}function requireRoot(){if(process.geteuid?.()!==0){fail5("managed startup requires container effective uid 0")}}function modeOf3(stat){return stat.mode&511}function requireRootOwnedDirectory(target,mode){let stat;try{stat=import_node_fs3.default.lstatSync(target)}catch{fail5(`required root-owned directory is missing: ${target}`)}if(stat.isSymbolicLink()||!stat.isDirectory()||stat.uid!==0||stat.gid!==0||modeOf3(stat)!==mode){fail5(`${target} must be a root:root directory with mode ${mode.toString(8)}`)}}function ensureRootOwnedDirectory(target,mode=ROOT_OWNED_DIRECTORY_MODE){const parent=import_node_path3.default.dirname(target);const parentStat=import_node_fs3.default.lstatSync(parent);if(parentStat.isSymbolicLink()||!parentStat.isDirectory()||parentStat.uid!==0||parentStat.gid!==0||(modeOf3(parentStat)&18)!==0){fail5(`refusing unsafe parent directory for ${target}`)}try{import_node_fs3.default.mkdirSync(target,{mode});import_node_fs3.default.chownSync(target,0,0);import_node_fs3.default.chmodSync(target,mode)}catch(error){if(error.code!=="EEXIST"){fail5(`could not create ${target}`)}}requireRootOwnedDirectory(target,mode)}function requireSafeExistingRootTarget(target){let stat;try{stat=import_node_fs3.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return;fail5(`could not inspect ${target}`)}if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1||stat.uid!==0||stat.gid!==0){fail5(`refusing to replace unsafe root-owned file ${target}`)}}function atomicWriteRootFile(target,contents,mode){const parent=import_node_path3.default.dirname(target);const parentStat=import_node_fs3.default.lstatSync(parent);if(parentStat.isSymbolicLink()||!parentStat.isDirectory()||parentStat.uid!==0||parentStat.gid!==0||(modeOf3(parentStat)&18)!==0){fail5(`refusing unsafe root-owned file parent ${parent}`)}requireSafeExistingRootTarget(target);const temporary=import_node_path3.default.join(parent,`.${import_node_path3.default.basename(target)}.${(0,import_node_crypto6.randomBytes)(12).toString("hex")}`);let descriptor;try{descriptor=import_node_fs3.default.openSync(temporary,import_node_fs3.default.constants.O_CREAT|import_node_fs3.default.constants.O_EXCL|import_node_fs3.default.constants.O_WRONLY|import_node_fs3.default.constants.O_NOFOLLOW,384);import_node_fs3.default.fchownSync(descriptor,0,0);import_node_fs3.default.writeFileSync(descriptor,contents);import_node_fs3.default.fchmodSync(descriptor,mode);import_node_fs3.default.fsyncSync(descriptor);import_node_fs3.default.closeSync(descriptor);descriptor=void 0;import_node_fs3.default.renameSync(temporary,target)}catch(error){if(descriptor!==void 0)import_node_fs3.default.closeSync(descriptor);try{import_node_fs3.default.unlinkSync(temporary)}catch{}fail5(`could not atomically write ${target}: ${error.message}`)}const stat=import_node_fs3.default.lstatSync(target);if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1||stat.uid!==0||stat.gid!==0||modeOf3(stat)!==mode){fail5(`root-owned output failed metadata verification: ${target}`)}}function removeSafeRootFile(target){requireSafeExistingRootTarget(target);try{import_node_fs3.default.unlinkSync(target)}catch(error){if(error.code!=="ENOENT"){fail5(`could not remove ${target}`)}}}function trustedExecutable(target){try{const stat=import_node_fs3.default.lstatSync(target);return!stat.isSymbolicLink()&&stat.isFile()&&stat.uid===0&&stat.gid===0&&(modeOf3(stat)&18)===0&&(modeOf3(stat)&73)!==0}catch{return false}}function readSandboxIdentity(){const readId=flag=>{const result=(0,import_node_child_process.spawnSync)("/usr/bin/id",[flag,"sandbox"],{encoding:"utf8",env:{PATH:FIXED_PATH}});const value=result.stdout.trim();if(result.status!==0||!/^[1-9][0-9]*$/u.test(value)){fail5("could not resolve the sandbox account")}return value};return{uid:readId("-u"),gid:readId("-g")}}function managedStartupSandboxPrefix(){if(trustedExecutable("/usr/bin/setpriv")){const identity=readSandboxIdentity();return["/usr/bin/setpriv",`--reuid=${identity.uid}`,`--regid=${identity.gid}`,"--init-groups","--"]}return fail5("a trusted setpriv executable is required")}function commandEnvironment(configurationEnvironment,applicationRuntime){const env=applyManagedStartupCommandEnvironmentPlan({...process.env,...configurationEnvironment,HOME:"/sandbox",PATH:FIXED_PATH,NPM_CONFIG_OFFLINE:"true",npm_config_offline:"true",PIP_DISABLE_PIP_VERSION_CHECK:"1",PIP_NO_INDEX:"1",UV_OFFLINE:"1"},applicationRuntime);delete env[MANAGED_STARTUP_PROFILE_ENV];delete env[MANAGED_STARTUP_CA_ENV];return env}function execute(argv,runAs,configurationEnvironment,applicationRuntime,capture=false){if(argv.length===0)fail5("refusing an empty managed startup command");const command=runAs==="sandbox"?[...managedStartupSandboxPrefix(),...argv]:[...argv];const result=(0,import_node_child_process.spawnSync)(command[0],command.slice(1),{encoding:"utf8",env:commandEnvironment(configurationEnvironment,applicationRuntime),stdio:capture?"pipe":"inherit"});if(result.error){fail5(`could not execute ${argv[0]}: ${result.error.message}`)}if(result.status!==0){const detail=capture?`: ${(result.stderr||result.stdout).trim()}`:"";fail5(`${argv[0]} exited with status ${String(result.status??"unknown")}${detail}`)}return{status:result.status,stdout:result.stdout??"",stderr:result.stderr??""}}function generatorCommand(agent){switch(agent){case"openclaw":return["/usr/local/bin/node","--experimental-strip-types","/scripts/generate-openclaw-config.mts"];case"hermes":return["/usr/local/bin/node","--experimental-strip-types","/opt/nemoclaw-hermes-config/generate-config.ts"];case"langchain-deepagents-code":return["/usr/local/bin/node","--experimental-strip-types","/opt/nemoclaw-deepagents-code/generate-config.ts"];case"pi":return["/usr/local/bin/node","--experimental-strip-types","/opt/nemoclaw-pi/generate-config.ts"]}}function messagingCommand(agent,phase,mode){return["/usr/local/bin/node","--experimental-strip-types","/src/lib/messaging/applier/build/messaging-build-applier.mts","--agent",agent,"--phase",phase,"--mode",mode,...phase==="post-agent-install"?["--managed-startup-runtime"]:[]]}function assertActionAgent(inputAgent,actionAgent){if(inputAgent!==actionAgent){failActionPlan(`action for ${actionAgent} cannot be used by ${inputAgent}`)}}function buildManagedStartupImageActionPlan(input){const inputAgent=exactActionPlanAgent(input.agent);const commands=[];let dashboardActions=0;let generateActions=0;let runtimeMessagingActions=0;let postMessagingActions=0;for(const action of input.actions){switch(action.kind){case"configure-dashboard":{if(action.dashboard.agent!==input.agent){failActionPlan(`dashboard for ${action.dashboard.agent} cannot be used by ${input.agent}`)}dashboardActions+=1;break}case"generate-agent-config":{assertActionAgent(inputAgent,exactActionPlanAgent(action.agent));if(action.runAs!=="sandbox"){failActionPlan("agent configuration generation must run as sandbox")}generateActions+=1;commands.push({action:"generate-agent-config",runAs:action.runAs,argv:generatorCommand(action.agent)});break}case"apply-messaging-plan":{assertActionAgent(inputAgent,exactActionPlanAgent(action.agent));if(action.mode!=="apply"&&action.mode!=="clear"){failActionPlan("messaging intent must be apply or clear")}if(action.phase==="runtime-setup"){if(action.runAs!=="root"){failActionPlan("messaging runtime setup must run as root")}runtimeMessagingActions+=1;commands.push({action:"messaging-runtime-setup",runAs:action.runAs,argv:messagingCommand(action.agent,action.phase,action.mode)})}else if(action.phase==="post-agent-install"){if(action.runAs!=="sandbox"){failActionPlan("messaging post-agent configuration must run as sandbox")}postMessagingActions+=1;commands.push({action:"messaging-post-agent-install",runAs:action.runAs,argv:messagingCommand(action.agent,action.phase,action.mode)})}else{failActionPlan("unsupported messaging construction phase")}break}default:failActionPlan("unsupported managed startup construction action")}}if(dashboardActions!==1){failActionPlan("exactly one dashboard construction action is required")}if(generateActions!==1){failActionPlan("exactly one agent config construction action is required")}const supportsMessaging=MANAGED_STARTUP_MESSAGING_AGENTS.includes(inputAgent);const expectedMessagingActions=supportsMessaging?1:0;if(runtimeMessagingActions!==expectedMessagingActions||postMessagingActions!==expectedMessagingActions){failActionPlan(`${inputAgent} requires ${String(expectedMessagingActions)} action for each messaging phase`)}const expectedOrder=supportsMessaging?["messaging-runtime-setup","generate-agent-config","messaging-post-agent-install"]:["generate-agent-config"];if(commands.some((command,index)=>command.action!==expectedOrder[index])){failActionPlan(`${inputAgent} image actions are not in the required construction order`)}return Object.freeze(commands.map(command=>Object.freeze({...command,argv:Object.freeze([...command.argv])})))}function prepareMessagingRuntimeTarget(mode){if(mode==="clear"){removeSafeRootFile(MESSAGING_RUNTIME_PLAN_FILE);return}requireSafeExistingRootTarget(MESSAGING_RUNTIME_PLAN_FILE);try{import_node_fs3.default.unlinkSync(MESSAGING_RUNTIME_PLAN_FILE)}catch(error){if(error.code!=="ENOENT"){fail5("could not prepare the messaging runtime-plan target")}}}function verifyMessagingRuntimeTarget(mode){if(mode==="clear"){if(import_node_fs3.default.existsSync(MESSAGING_RUNTIME_PLAN_FILE)){fail5("clear messaging profile left a runtime-plan artifact")}return}const stat=import_node_fs3.default.lstatSync(MESSAGING_RUNTIME_PLAN_FILE);if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1||stat.uid!==0||stat.gid!==0||modeOf3(stat)!==420){fail5("messaging runtime-plan artifact failed root ownership validation")}}function runInternalSandboxAction(action,configurationEnvironment,applicationRuntime,extraEnvironment={}){execute(["/usr/local/bin/node",MANAGED_STARTUP_RUNTIME_EXECUTABLE,`--internal-${action}`],"sandbox",{...configurationEnvironment,...extraEnvironment},applicationRuntime)}function sealOpenClawConfiguration(configurationEnvironment,applicationRuntime){const validation=execute(["/usr/local/bin/openclaw","config","validate","--json"],"sandbox",{...configurationEnvironment,OPENCLAW_CONFIG_PATH:"/sandbox/.openclaw/openclaw.json"},applicationRuntime,true);let parsed;try{parsed=JSON.parse(validation.stdout)}catch{fail5("OpenClaw config validation did not emit JSON")}if(typeof parsed!=="object"||parsed===null||parsed.valid!==true){fail5("OpenClaw rejected the generated managed startup config")}runInternalSandboxAction("write-openclaw-hash",configurationEnvironment,applicationRuntime)}function sameStableFileMetadata(left,right){return left.dev===right.dev&&left.ino===right.ino&&left.mode===right.mode&&left.nlink===right.nlink&&left.uid===right.uid&&left.gid===right.gid&&left.size===right.size&&left.mtimeNs===right.mtimeNs&&left.ctimeNs===right.ctimeNs}function readStableRegularFileSnapshot(target,maxBytes){if(typeof import_node_fs3.default.constants.O_NOFOLLOW!=="number"){fail5("O_NOFOLLOW is unavailable for managed startup file reads")}const nonblock=typeof import_node_fs3.default.constants.O_NONBLOCK==="number"?import_node_fs3.default.constants.O_NONBLOCK:0;let descriptor;try{descriptor=import_node_fs3.default.openSync(target,import_node_fs3.default.constants.O_RDONLY|import_node_fs3.default.constants.O_NOFOLLOW|nonblock)}catch(error){if(error.code==="ENOENT")throw error;fail5(`refusing unsafe or unreadable file ${target}`)}try{const before=import_node_fs3.default.fstatSync(descriptor,{bigint:true});if(!before.isFile()||before.nlink!==1n||before.size<1n||before.size>BigInt(maxBytes)){fail5(`refusing unsafe or oversized file ${target}`)}const bytes=Buffer.alloc(Number(before.size));let offset=0;while(offsetcandidate&&candidate!==MANAGED_STARTUP_MERGED_CA_FILE&&values.indexOf(candidate)===index);let base=null;for(const candidate of candidates){base=safeTrustBundle(candidate);if(base)break}const merged=Buffer.concat([...base?[base,Buffer.from("\n","utf8")]:[],corporate,...corporate.at(-1)===10?[]:[Buffer.from("\n","utf8")]]);atomicWriteRootFile(MANAGED_STARTUP_MERGED_CA_FILE,merged,292);return true}function shellSingleQuote(value){if(value.includes("\0")||/[\r\n]/u.test(value)){fail5("runtime environment values must be single-line text")}return`'${value.replaceAll("'",`'"'"'`)}'`}function serializeManagedStartupRuntimeEnvironment(environment,corporateCaMerged,configurationEnvironment={},applicationRuntime={exportEnvironment:{},unsetEnvironment:[]}){const{output,unsetNames}=materializeManagedStartupRuntimeEnvironment(environment,corporateCaMerged,configurationEnvironment,applicationRuntime);const unsetLines=unsetNames.map(name=>`unset ${name}`);const exportLines=Object.entries(output).sort(([left],[right])=>left.localeCompare(right)).map(([name,value])=>`export ${name}=${shellSingleQuote(value)}`);return`${[...unsetLines,...exportLines].join("\n")} -`}function materializeManagedStartupRuntimeEnvironment(environment,corporateCaMerged,configurationEnvironment={},applicationRuntime={exportEnvironment:{},unsetEnvironment:[]}){const validatedApplicationRuntime=validateManagedStartupApplicationRuntimePlan(applicationRuntime);const output={...environment,...validatedApplicationRuntime.exportEnvironment,NEMOCLAW_MANAGED_STARTUP_APPLIED:"1"};if(corporateCaMerged){for(const name of["CURL_CA_BUNDLE","GIT_SSL_CAINFO","NODE_EXTRA_CA_CERTS","REQUESTS_CA_BUNDLE","SSL_CERT_FILE"]){output[name]=MANAGED_STARTUP_MERGED_CA_FILE}output._NEMOCLAW_CORPORATE_CA_MERGED="1"}for(const name of[...Object.keys(configurationEnvironment),...Object.keys(output)]){if(!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)){fail5(`invalid runtime environment key ${JSON.stringify(name)}`)}}const unsetNames=new Set([...Object.keys(configurationEnvironment).filter(name=>!Object.hasOwn(output,name)),...validatedApplicationRuntime.unsetEnvironment]);for(const name of validatedApplicationRuntime.unsetEnvironment){if(Object.hasOwn(output,name)){fail5(`runtime environment cannot both export and unset ${name}`)}}for(const name of unsetNames){if(!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)){fail5(`invalid runtime environment key ${JSON.stringify(name)}`)}}return{output,unsetNames:[...unsetNames].sort()}}function serializeManagedStartupCompletionMarker(marker){if(marker.schemaVersion!==MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION||!MANAGED_STARTUP_AGENTS.includes(marker.agent)||!SHA256_RE4.test(marker.profileFingerprint)||!SHA256_RE4.test(marker.runtimeEnvironmentSha256)||typeof marker.corporateCaMerged!=="boolean"){fail5("managed startup completion marker is invalid")}return`${JSON.stringify({agent:marker.agent,corporateCaMerged:marker.corporateCaMerged,profileFingerprint:marker.profileFingerprint,runtimeEnvironmentSha256:marker.runtimeEnvironmentSha256,schemaVersion:marker.schemaVersion})} -`}function parseManagedStartupCompletionMarker(text){let parsed;try{parsed=JSON.parse(text)}catch{fail5("managed startup completion marker is not valid JSON")}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail5("managed startup completion marker must be an object")}const record=parsed;const expectedKeys=["agent","corporateCaMerged","profileFingerprint","runtimeEnvironmentSha256","schemaVersion"];if(Object.keys(record).sort().join(",")!==expectedKeys.sort().join(",")||record.schemaVersion!==MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION||typeof record.agent!=="string"||!MANAGED_STARTUP_AGENTS.includes(record.agent)||typeof record.profileFingerprint!=="string"||!SHA256_RE4.test(record.profileFingerprint)||typeof record.runtimeEnvironmentSha256!=="string"||!SHA256_RE4.test(record.runtimeEnvironmentSha256)||typeof record.corporateCaMerged!=="boolean"){fail5("managed startup completion marker has an invalid schema")}const marker={schemaVersion:MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION,agent:record.agent,profileFingerprint:record.profileFingerprint,runtimeEnvironmentSha256:record.runtimeEnvironmentSha256,corporateCaMerged:record.corporateCaMerged};if(serializeManagedStartupCompletionMarker(marker)!==text){fail5("managed startup completion marker is not canonical")}return marker}function verifyManagedStartupImageCompletion(expectedAgentInput,expectedFingerprint,completionFile=MANAGED_STARTUP_COMPLETION_FILE,runtimeEnvironmentFile=MANAGED_STARTUP_RUNTIME_ENV_FILE){const expectedAgent=exactAgent2(expectedAgentInput);if(!SHA256_RE4.test(expectedFingerprint)){fail5("startup completion expected profile fingerprint is invalid")}const{bytes,stat}=readStableRegularFileSnapshot(completionFile,MAX_MANAGED_STARTUP_COMPLETION_BYTES);if(stat.nlink!==1n||stat.uid!==0n||stat.gid!==0n||Number(stat.mode&0o777n)!==292){fail5("managed startup completion marker must be root:root mode 0444")}const marker=parseManagedStartupCompletionMarker(bytes.toString("utf8"));if(marker.agent!==expectedAgent||marker.profileFingerprint!==expectedFingerprint){fail5("managed startup completion marker does not match the requested profile")}const runtimeEnvironment=readStableRegularFileSnapshot(runtimeEnvironmentFile,MAX_MANAGED_STARTUP_RUNTIME_ENVIRONMENT_BYTES);if(runtimeEnvironment.stat.nlink!==1n||runtimeEnvironment.stat.uid!==0n||runtimeEnvironment.stat.gid!==0n||Number(runtimeEnvironment.stat.mode&0o777n)!==292){fail5("managed startup runtime environment must be root:root mode 0444")}const runtimeEnvironmentSha256=(0,import_node_crypto6.createHash)("sha256").update(runtimeEnvironment.bytes).digest("hex");if(runtimeEnvironmentSha256!==marker.runtimeEnvironmentSha256){fail5("managed startup completion marker runtime environment digest mismatch")}return{agent:expectedAgent,fingerprint:expectedFingerprint}}function waitForManagedStartupImageCompletion(expectedAgentInput,expectedFingerprint,timeoutSeconds=600){if(!Number.isSafeInteger(timeoutSeconds)||timeoutSeconds<1||timeoutSeconds>3600){fail5("startup completion wait timeout must be an integer from 1 to 3600 seconds")}const deadline=Date.now()+timeoutSeconds*1e3;while(true){try{return verifyManagedStartupImageCompletion(expectedAgentInput,expectedFingerprint)}catch(error){if(error.code!=="ENOENT")throw error;if(Date.now()>=deadline){fail5(`startup completion was not published within ${String(timeoutSeconds)} seconds`)}Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,250)}}}function applyAdapter(context,mapped){if(mapped.agent!==context.agent){fail5(`mapped ${mapped.agent} environment for ${context.agent}`)}const commandPlan=buildManagedStartupImageActionPlan({agent:mapped.agent,actions:mapped.actions});let commandIndex=0;for(const action of mapped.actions){if(action.kind==="configure-dashboard")continue;const command=commandPlan[commandIndex];if(!command)fail5(`missing image command for ${action.kind}`);commandIndex+=1;if(action.kind==="apply-messaging-plan"){if(action.phase==="runtime-setup"){prepareMessagingRuntimeTarget(action.mode)}execute(command.argv,command.runAs,mapped.configurationEnvironment,mapped.applicationRuntime);if(action.phase==="runtime-setup"){verifyMessagingRuntimeTarget(action.mode)}continue}execute(command.argv,command.runAs,mapped.configurationEnvironment,mapped.applicationRuntime)}if(commandIndex!==commandPlan.length){fail5("image action plan contains an unmatched command")}switch(context.agent){case"openclaw":sealOpenClawConfiguration(mapped.configurationEnvironment,mapped.applicationRuntime);break;case"hermes":installHermesManagedPolicy();sealHermesConfiguration(mapped.configurationEnvironment,mapped.applicationRuntime);normalizeHermesManagedConfiguration();break;case"langchain-deepagents-code":break}installRootOwnedMaterials(mapped.materials);installCorporateCa(context.corporateCaPath);mergeCorporateCa(context.corporateCaPath)}function adapters(mapped){return MANAGED_STARTUP_AGENTS.map(agent=>({agent,apply:context=>applyAdapter(context,mapped)}))}async function applyManagedStartupImageProfile(expectedAgentInput,env=process.env){requireRoot();const expectedAgent=exactAgent2(expectedAgentInput);if(env.NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION!=="1"){fail5("startup profiles require a complete managed image")}const encodedProfile=env[MANAGED_STARTUP_PROFILE_ENV];if(!encodedProfile)fail5(`${MANAGED_STARTUP_PROFILE_ENV} is required`);let profile;try{profile=decodeManagedStartupProfile(encodedProfile)}catch(error){fail5(error.message)}if(profile.agent!==expectedAgent){fail5(`managed startup profile targets ${profile.agent}, expected ${expectedAgent}`)}const mapped=mapManagedStartupProfileToAgentEnvironment(profile,env);validateManagedStartupApplicationRuntimePlan(mapped.applicationRuntime);ensureRootOwnedDirectory(ROOT_STATE_PARENT);ensureRootOwnedDirectory(ROOT_RUNTIME_DIRECTORY);const result=await coordinateManagedStartupApplication({encodedProfile,expectedAgent,...env[MANAGED_STARTUP_CA_ENV]===void 0?{}:{corporateCaB64:env[MANAGED_STARTUP_CA_ENV]}},adapters(mapped));if(mapped.agent!==result.application.profile.agent){fail5(`mapped ${mapped.agent} environment for ${result.application.profile.agent}`)}if(expectedAgent==="hermes"&&!result.adapterApplied){normalizeHermesManagedConfiguration()}let corporateCaMerged;if(result.adapterApplied){corporateCaMerged=result.application.corporateCaPath!==null}else{verifyRootOwnedMaterials(mapped.materials);if(result.application.corporateCaPath===null){if(import_node_fs3.default.existsSync(MANAGED_STARTUP_CORPORATE_CA_FILE)){fail5("committed profile without a corporate CA has a stale CA material")}}else{const expected=readStableRegularFile(result.application.corporateCaPath,128*1024);const installed=readStableRegularFile(MANAGED_STARTUP_CORPORATE_CA_FILE,128*1024);if(!expected.equals(installed)){fail5("committed corporate CA material drifted")}}corporateCaMerged=mergeCorporateCa(result.application.corporateCaPath)}const runtimeEnvironment=serializeManagedStartupRuntimeEnvironment(mapped.runtimeEnvironment,corporateCaMerged,mapped.configurationEnvironment,mapped.applicationRuntime);atomicWriteRootFile(MANAGED_STARTUP_RUNTIME_ENV_FILE,runtimeEnvironment,292);atomicWriteRootFile(MANAGED_STARTUP_COMPLETION_FILE,serializeManagedStartupCompletionMarker({schemaVersion:MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION,agent:expectedAgent,profileFingerprint:result.application.fingerprint,runtimeEnvironmentSha256:(0,import_node_crypto6.createHash)("sha256").update(runtimeEnvironment,"utf8").digest("hex"),corporateCaMerged}),292);return{agent:expectedAgent,adapterApplied:result.adapterApplied,fingerprint:result.application.fingerprint,runtimeEnvironmentFile:MANAGED_STARTUP_RUNTIME_ENV_FILE}}function completionAlreadyPublished(request){try{verifyManagedStartupImageCompletion(request.agent,request.profileFingerprint);return true}catch(error){if(error.code==="ENOENT")return false;throw error}}async function applyManagedStartupRootRequest(request,env=process.env,options={}){requireRoot();const profile=decodeManagedStartupProfile(request.encodedProfile);if(profile.agent!==request.agent||fingerprintManagedStartupProfile(profile)!==request.profileFingerprint){fail5("root application request identity does not match its profile")}const imageEnvironment={HOME:"/root",PATH:FIXED_PATH,NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION:"1",...selectManagedStartupApplicationRuntimeEnvironment(env),[MANAGED_STARTUP_PROFILE_ENV]:request.encodedProfile,...request.corporateCaB64===null?{}:{[MANAGED_STARTUP_CA_ENV]:request.corporateCaB64}};mapManagedStartupProfileToAgentEnvironment(profile,imageEnvironment);const alreadyPublished=completionAlreadyPublished(request);const bootstrapIdentity=options.bootstrapIdentity??null;const transactionStatus=alreadyPublished&&bootstrapIdentity!==null?getManagedStartupSharedStateTransactionStatus({agent:request.agent,profileFingerprint:request.profileFingerprint,bootstrapIdentity}):null;if(transactionStatus==="none"){fail5("completed startup profile has no shared-state authority for this bootstrap attempt")}if(!alreadyPublished){ensureRootOwnedDirectory(ROOT_STATE_PARENT);beginManagedStartupSharedStateTransaction(profile,{bootstrapIdentity})}const result=await applyManagedStartupImageProfile(request.agent,imageEnvironment);return{...result,transactionPending:!alreadyPublished||transactionStatus==="pending"}}function readBoundedRootApplyStdin(){const chunks=[];let total=0;while(true){const chunk=Buffer.alloc(16*1024);const read=import_node_fs3.default.readSync(0,chunk,0,chunk.length,null);if(read===0)break;total+=read;if(total>MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES){fail5("root application stdin exceeds its bounded transport")}chunks.push(chunk.subarray(0,read))}const bytes=Buffer.concat(chunks,total);const text=bytes.toString("utf8");if(text.includes("\0")||!Buffer.from(text,"utf8").equals(bytes)){fail5("root application stdin must be valid UTF-8 without NUL bytes")}return text}function writeSandboxFileAtomically(target,contents,mode){const parent=import_node_path3.default.dirname(target);const parentStat=import_node_fs3.default.lstatSync(parent);if(parentStat.isSymbolicLink()||!parentStat.isDirectory()||parentStat.uid!==process.geteuid?.()||parentStat.gid!==process.getegid?.()){fail5(`refusing unsafe sandbox-owned directory ${parent}`)}const temporary=import_node_path3.default.join(parent,`.${import_node_path3.default.basename(target)}.${(0,import_node_crypto6.randomBytes)(12).toString("hex")}`);let descriptor;try{descriptor=import_node_fs3.default.openSync(temporary,import_node_fs3.default.constants.O_CREAT|import_node_fs3.default.constants.O_EXCL|import_node_fs3.default.constants.O_WRONLY|import_node_fs3.default.constants.O_NOFOLLOW,384);import_node_fs3.default.writeFileSync(descriptor,contents);import_node_fs3.default.fchmodSync(descriptor,mode);import_node_fs3.default.fsyncSync(descriptor);import_node_fs3.default.closeSync(descriptor);descriptor=void 0;import_node_fs3.default.renameSync(temporary,target)}catch(error){if(descriptor!==void 0)import_node_fs3.default.closeSync(descriptor);try{import_node_fs3.default.unlinkSync(temporary)}catch{}fail5(`could not write sandbox-owned file ${target}: ${error.message}`)}}function internalWriteOpenClawHash(){if(process.geteuid?.()===0)fail5("sandbox hash writer must not run as root");const configPath="/sandbox/.openclaw/openclaw.json";const config=readStableRegularFile(configPath,16*1024*1024);const text=`${(0,import_node_crypto6.createHash)("sha256").update(config).digest("hex")} openclaw.json +`}function requireExactKeys(record,keys){if(Object.keys(record).sort().join(",")!==[...keys].sort().join(",")){fail4("transaction manifest contains unexpected fields")}}function parseCommitReceipt(text){let parsed;try{parsed=JSON.parse(text)}catch{fail4("commit receipt is not valid JSON")}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail4("commit receipt must be an object")}const record=parsed;requireExactKeys(record,["agent","bootstrapIdentity","profileFingerprint","schemaVersion"]);if(record.schemaVersion!==TRANSACTION_SCHEMA_VERSION||!MANAGED_STARTUP_AGENTS.includes(String(record.agent))||typeof record.profileFingerprint!=="string"||!/^[a-f0-9]{64}$/u.test(record.profileFingerprint)||typeof record.bootstrapIdentity!=="string"||!/^[a-f0-9]{64}$/u.test(record.bootstrapIdentity)){fail4("commit receipt has an invalid envelope")}const receipt={schemaVersion:TRANSACTION_SCHEMA_VERSION,agent:record.agent,profileFingerprint:record.profileFingerprint,bootstrapIdentity:record.bootstrapIdentity};if(canonicalCommitReceipt(receipt)!==text){fail4("commit receipt is not canonical")}return receipt}function safeMetadata(value){return Number.isSafeInteger(value)&&value>=0}function parseManifest(text){let parsed;try{parsed=JSON.parse(text)}catch{fail4("transaction manifest is not valid JSON")}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail4("transaction manifest must be an object")}const record=parsed;const hasBootstrapIdentity=Object.hasOwn(record,"bootstrapIdentity");requireExactKeys(record,hasBootstrapIdentity?["agent","bootstrapIdentity","directories","files","profileFingerprint","schemaVersion"]:["agent","directories","files","profileFingerprint","schemaVersion"]);const bootstrapIdentity=hasBootstrapIdentity?record.bootstrapIdentity:null;if(record.schemaVersion!==TRANSACTION_SCHEMA_VERSION||!MANAGED_STARTUP_AGENTS.includes(String(record.agent))||typeof record.profileFingerprint!=="string"||!/^[a-f0-9]{64}$/u.test(record.profileFingerprint)||!(bootstrapIdentity===null||typeof bootstrapIdentity==="string"&&/^[a-f0-9]{64}$/u.test(bootstrapIdentity))||!Array.isArray(record.files)||!Array.isArray(record.directories)||record.files.length>MAX_TRANSACTION_FILES||record.directories.length>MAX_TRANSACTION_FILES*4){fail4("transaction manifest has an invalid envelope")}const files=record.files.map(value=>{if(typeof value!=="object"||value===null||Array.isArray(value)){return fail4("transaction file receipt must be an object")}const receipt=value;if(typeof receipt.path!=="string"){return fail4("transaction file receipt path must be a string")}const receiptPath=safeRelativePath(receipt.path);if(receipt.state==="absent"){requireExactKeys(receipt,["path","state"]);return{path:receiptPath,state:"absent"}}requireExactKeys(receipt,["backup","gid","mode","path","sha256","size","state","uid"]);if(receipt.state!=="file"||typeof receipt.backup!=="string"||!/^[0-9]{3}\.bin$/u.test(receipt.backup)||typeof receipt.sha256!=="string"||!/^[a-f0-9]{64}$/u.test(receipt.sha256)||!safeMetadata(receipt.size)||receipt.size>MAX_TRANSACTION_FILE_BYTES||!safeMetadata(receipt.uid)||!safeMetadata(receipt.gid)||!safeMetadata(receipt.mode)||receipt.mode>4095){return fail4("transaction file receipt is invalid")}return{path:receiptPath,state:"file",backup:receipt.backup,sha256:receipt.sha256,size:receipt.size,uid:receipt.uid,gid:receipt.gid,mode:receipt.mode}});const directories=record.directories.map(value=>{if(typeof value!=="object"||value===null||Array.isArray(value)){return fail4("transaction directory receipt must be an object")}const receipt=value;if(typeof receipt.path!=="string"){return fail4("transaction directory receipt path must be a string")}const receiptPath=safeRelativePath(receipt.path);if(receipt.state==="absent"){requireExactKeys(receipt,["path","state"]);return{path:receiptPath,state:"absent"}}requireExactKeys(receipt,["gid","mode","path","state","uid"]);if(receipt.state!=="directory"||!safeMetadata(receipt.uid)||!safeMetadata(receipt.gid)||!safeMetadata(receipt.mode)||receipt.mode>4095){return fail4("transaction directory receipt is invalid")}return{path:receiptPath,state:"directory",uid:receipt.uid,gid:receipt.gid,mode:receipt.mode}});const filePaths=files.map(receipt=>receipt.path);const directoryPaths=directories.map(receipt=>receipt.path);const backupNames=files.filter(receipt=>receipt.state==="file").map(receipt=>receipt.backup);if(new Set(filePaths).size!==filePaths.length||new Set(directoryPaths).size!==directoryPaths.length||new Set(backupNames).size!==backupNames.length){fail4("transaction manifest contains duplicate receipts")}const manifest={schemaVersion:TRANSACTION_SCHEMA_VERSION,agent:record.agent,profileFingerprint:record.profileFingerprint,bootstrapIdentity,files,directories};const canonical=hasBootstrapIdentity?canonicalManifest(manifest):canonicalLegacyManifest(manifest);if(canonical!==text){fail4("transaction manifest is not canonical")}return manifest}function requireTrustedTransactionPath(target,mode,options){const stat=import_node_fs2.default.lstatSync(target);if(stat.isSymbolicLink()||(mode===TRANSACTION_DIRECTORY_MODE?!stat.isDirectory():!stat.isFile())||!options.readOnlyReceipt&&(stat.uid!==options.trustedUid||stat.gid!==options.trustedGid)||modeOf2(stat)!==mode){fail4(`transaction artifact has unsafe metadata: ${target}`)}}function requireReadOnlyReceiptMount(target,options){if(!options.readOnlyReceipt)return;const probe=import_node_path2.default.join(target,".nemoclaw-write-probe");let descriptor;try{descriptor=import_node_fs2.default.openSync(probe,import_node_fs2.default.constants.O_CREAT|import_node_fs2.default.constants.O_EXCL|import_node_fs2.default.constants.O_WRONLY|import_node_fs2.default.constants.O_NOFOLLOW,384);import_node_fs2.default.closeSync(descriptor);descriptor=void 0;import_node_fs2.default.unlinkSync(probe)}catch(error){if(descriptor!==void 0)import_node_fs2.default.closeSync(descriptor);if(error.code==="EROFS")return;fail4("copied receipt must be mounted on a read-only filesystem")}fail4("copied receipt mount is writable")}function loadManifest(options){requireTransactionBoundaries(options);if(!pathExistsNoFollow(options.transactionDirectory))return null;requireTrustedTransactionPath(options.transactionDirectory,TRANSACTION_DIRECTORY_MODE,options);requireReadOnlyReceiptMount(options.transactionDirectory,options);requireTrustedTransactionPath(options.backupDirectory,TRANSACTION_DIRECTORY_MODE,options);requireTrustedTransactionPath(options.manifestFile,TRANSACTION_FILE_MODE,options);const stable=readStableFile(options.manifestFile,MAX_MANIFEST_BYTES);if(!options.readOnlyReceipt&&(Number(stable.stat.uid)!==options.trustedUid||Number(stable.stat.gid)!==options.trustedGid)||Number(stable.stat.mode&0o7777n)!==TRANSACTION_FILE_MODE){fail4("transaction manifest ownership changed while it was read")}return parseManifest(stable.bytes.toString("utf8"))}function transactionOptionsAt(options,transactionDirectory){return{...options,transactionDirectory,backupDirectory:import_node_path2.default.join(transactionDirectory,"backups"),manifestFile:import_node_path2.default.join(transactionDirectory,"manifest.json")}}function loadCommitReceipt(options){requireTransactionBoundaries(options);if(!pathExistsNoFollow(options.commitReceiptDirectory))return null;requireTrustedTransactionPath(options.commitReceiptDirectory,TRANSACTION_DIRECTORY_MODE,options);if(pathExistsNoFollow(options.commitReceiptFile)){requireReadOnlyReceiptMount(options.commitReceiptDirectory,options);requireTrustedTransactionPath(options.commitReceiptFile,TRANSACTION_FILE_MODE,options);const stable=readStableFile(options.commitReceiptFile,MAX_COMMIT_RECEIPT_BYTES);if(!options.readOnlyReceipt&&(Number(stable.stat.uid)!==options.trustedUid||Number(stable.stat.gid)!==options.trustedGid)||Number(stable.stat.mode&0o7777n)!==TRANSACTION_FILE_MODE){fail4("commit receipt ownership changed while it was read")}return{receipt:parseCommitReceipt(stable.bytes.toString("utf8")),compact:true}}const stagedOptions=transactionOptionsAt(options,options.commitReceiptDirectory);const staged=loadManifest(stagedOptions);if(!staged||staged.bootstrapIdentity===null){fail4("durable commit staging receipt is incomplete")}verifyAllBackups(staged.files,stagedOptions);return{receipt:{schemaVersion:TRANSACTION_SCHEMA_VERSION,agent:staged.agent,profileFingerprint:staged.profileFingerprint,bootstrapIdentity:staged.bootstrapIdentity},compact:false}}function verifyBackup(receipt,options){const backupPath=import_node_path2.default.join(options.backupDirectory,receipt.backup);requireTrustedTransactionPath(backupPath,TRANSACTION_FILE_MODE,options);const stable=readStableFile(backupPath,MAX_TRANSACTION_FILE_BYTES);const digest=(0,import_node_crypto5.createHash)("sha256").update(stable.bytes).digest("hex");if(stable.bytes.length!==receipt.size||digest!==receipt.sha256){fail4(`transaction backup does not match its receipt: ${receipt.path}`)}return stable.bytes}function verifyAllBackups(receipts,options){const backups=new Map;for(const receipt of receipts){if(receipt.state==="file"){backups.set(receipt.path,verifyBackup(receipt,options))}}return backups}function fileMatchesReceipt(target,receipt){let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return false;fail4(`could not inspect managed output ${target}`)}if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1)return false;const stable=readStableFile(target,MAX_TRANSACTION_FILE_BYTES);return stable.bytes.length===receipt.size&&(0,import_node_crypto5.createHash)("sha256").update(stable.bytes).digest("hex")===receipt.sha256&&Number(stable.stat.uid)===receipt.uid&&Number(stable.stat.gid)===receipt.gid&&Number(stable.stat.mode&0o7777n)===receipt.mode}function directoryMatchesReceipt(target,receipt){let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return false;fail4(`could not inspect managed output directory ${target}`)}return!stat.isSymbolicLink()&&stat.isDirectory()&&stat.uid===receipt.uid&&stat.gid===receipt.gid&&modeOf2(stat)===receipt.mode}function removeTransactionDirectory(options){requireTrustedTransactionPath(options.transactionDirectory,TRANSACTION_DIRECTORY_MODE,options);import_node_fs2.default.rmSync(options.transactionDirectory,{force:false,recursive:true});fsyncDirectory(options.transactionParentDirectory);if(pathExistsNoFollow(options.transactionDirectory)){fail4("transaction directory remained after cleanup")}}function assertCommitReceiptMatches(receipt,expected){if(receipt.agent!==expected.agent||expected.profileFingerprint!==void 0&&receipt.profileFingerprint!==expected.profileFingerprint||receipt.bootstrapIdentity!==expected.bootstrapIdentity){fail4("durable commit receipt belongs to a different bootstrap attempt")}}function loadCommitStagingManifest(options){if(!pathExistsNoFollow(options.manifestFile))return null;requireTrustedTransactionPath(options.manifestFile,TRANSACTION_FILE_MODE,options);const stable=readStableFile(options.manifestFile,MAX_MANIFEST_BYTES);if(Number(stable.stat.uid)!==options.trustedUid||Number(stable.stat.gid)!==options.trustedGid||Number(stable.stat.mode&0o7777n)!==TRANSACTION_FILE_MODE){fail4("durable commit staging manifest ownership changed while it was read")}return parseManifest(stable.bytes.toString("utf8"))}function retireInterruptedCommitReceiptWrites(receipt,options){const temporaryPattern=new RegExp(`^\\.${MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE.replace(".","\\.")}\\.[a-f0-9]{24}$`,"u");for(const entry of import_node_fs2.default.readdirSync(options.commitReceiptDirectory)){if(!temporaryPattern.test(entry))continue;const target=import_node_path2.default.join(options.commitReceiptDirectory,entry);const stat=import_node_fs2.default.lstatSync(target);if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1||stat.uid!==options.trustedUid||stat.gid!==options.trustedGid||![ATOMIC_TEMPORARY_FILE_MODE,TRANSACTION_FILE_MODE].includes(modeOf2(stat))){fail4("interrupted durable commit receipt write has unsafe metadata")}const stable=readStableFile(target,MAX_COMMIT_RECEIPT_BYTES);const mode=Number(stable.stat.mode&0o7777n);if(Number(stable.stat.uid)!==options.trustedUid||Number(stable.stat.gid)!==options.trustedGid||![ATOMIC_TEMPORARY_FILE_MODE,TRANSACTION_FILE_MODE].includes(mode)){fail4("interrupted durable commit receipt write changed during verification")}if(stable.bytes.length>0){let interruptedReceipt=null;try{interruptedReceipt=parseCommitReceipt(stable.bytes.toString("utf8"))}catch{}if(interruptedReceipt)assertCommitReceiptMatches(interruptedReceipt,receipt)}import_node_fs2.default.unlinkSync(target);fsyncDirectory(options.commitReceiptDirectory)}}function compactDurableCommitReceipt(state,options){if(!state.compact){atomicWriteTrustedFile(options.commitReceiptFile,canonicalCommitReceipt(state.receipt),TRANSACTION_FILE_MODE,options.trustedUid,options.trustedGid);fsyncDirectory(options.commitReceiptDirectory)}retireInterruptedCommitReceiptWrites(state.receipt,options);const stagedOptions=transactionOptionsAt(options,options.commitReceiptDirectory);const manifestExists=pathExistsNoFollow(stagedOptions.manifestFile);const backupsExist=pathExistsNoFollow(stagedOptions.backupDirectory);const unexpectedBeforeCleanup=import_node_fs2.default.readdirSync(options.commitReceiptDirectory).filter(entry=>![MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE,import_node_path2.default.basename(stagedOptions.backupDirectory),import_node_path2.default.basename(stagedOptions.manifestFile)].includes(entry));if(unexpectedBeforeCleanup.length!==0){fail4("durable commit receipt directory contains unexpected artifacts")}if(manifestExists){const staged=loadCommitStagingManifest(stagedOptions);if(!staged||staged.bootstrapIdentity===null){fail4("durable commit staging receipt disappeared during cleanup")}assertCommitReceiptMatches(state.receipt,{agent:staged.agent,profileFingerprint:staged.profileFingerprint,bootstrapIdentity:staged.bootstrapIdentity})}if(backupsExist){requireTrustedTransactionPath(stagedOptions.backupDirectory,TRANSACTION_DIRECTORY_MODE,options);import_node_fs2.default.rmSync(stagedOptions.backupDirectory,{force:false,recursive:true});fsyncDirectory(options.commitReceiptDirectory)}if(manifestExists){requireTrustedTransactionPath(stagedOptions.manifestFile,TRANSACTION_FILE_MODE,options);import_node_fs2.default.unlinkSync(stagedOptions.manifestFile);fsyncDirectory(options.commitReceiptDirectory)}const unexpected=import_node_fs2.default.readdirSync(options.commitReceiptDirectory).filter(entry=>entry!==MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE);if(unexpected.length!==0){fail4("durable commit receipt directory contains unexpected artifacts")}const verified=loadCommitReceipt(options);if(!verified?.compact)fail4("durable commit receipt did not compact successfully");assertCommitReceiptMatches(verified.receipt,state.receipt)}function beginManagedStartupSharedStateTransaction(profile,inputOptions={}){const options=resolveOptions(inputOptions);requireTransactionIdentity(options);if(options.readOnlyReceipt){fail4("cannot begin a transaction from a read-only rollback receipt")}requireTransactionBoundaries(options);const profileFingerprint=fingerprintManagedStartupProfile(profile);const committed=loadCommitReceipt(options);if(committed){if(options.bootstrapIdentity===null){fail4("a durable managed bootstrap commit receipt already exists")}assertCommitReceiptMatches(committed.receipt,{agent:profile.agent,profileFingerprint,bootstrapIdentity:options.bootstrapIdentity});fail4("this managed bootstrap attempt is already durably committed")}const pending=loadManifest(options);if(pending){if(pending.agent!==profile.agent||pending.profileFingerprint!==profileFingerprint||pending.bootstrapIdentity!==options.bootstrapIdentity){fail4("a pending managed startup transaction belongs to a different agent, profile fingerprint, or bootstrap attempt")}verifyAllBackups(pending.files,options);return false}const targets=managedOutputTargets(profile,options);if(targets.files.length>MAX_TRANSACTION_FILES){fail4("managed startup transaction has too many file targets")}const snapshots=targets.files.map((target,index)=>snapshotFile(target,index,profile.agent,options));const totalBytes=snapshots.reduce((sum,snapshot)=>sum+(snapshot.bytes?.length??0),0);if(totalBytes>MAX_TRANSACTION_TOTAL_BYTES){fail4("managed startup transaction backup exceeds the total size limit")}const directories=targets.directories.map(target=>snapshotDirectory(target,profile.agent,options));const manifest={schemaVersion:TRANSACTION_SCHEMA_VERSION,agent:profile.agent,profileFingerprint,bootstrapIdentity:options.bootstrapIdentity,files:snapshots.map(({receipt})=>receipt),directories};let createdTransactionIdentity;try{import_node_fs2.default.mkdirSync(options.transactionDirectory,{mode:TRANSACTION_DIRECTORY_MODE});const created=import_node_fs2.default.lstatSync(options.transactionDirectory,{bigint:true});if(!created.isDirectory()||created.isSymbolicLink()){fail4("new transaction path is not a directory")}createdTransactionIdentity={dev:created.dev,ino:created.ino,uid:created.uid,gid:created.gid};import_node_fs2.default.chownSync(options.transactionDirectory,options.trustedUid,options.trustedGid);import_node_fs2.default.chmodSync(options.transactionDirectory,TRANSACTION_DIRECTORY_MODE);fsyncDirectory(options.transactionParentDirectory);import_node_fs2.default.mkdirSync(options.backupDirectory,{mode:TRANSACTION_DIRECTORY_MODE});import_node_fs2.default.chownSync(options.backupDirectory,options.trustedUid,options.trustedGid);import_node_fs2.default.chmodSync(options.backupDirectory,TRANSACTION_DIRECTORY_MODE);fsyncDirectory(options.transactionDirectory);for(const snapshot of snapshots){if(snapshot.receipt.state!=="file"||snapshot.bytes===null)continue;atomicWriteTrustedFile(import_node_path2.default.join(options.backupDirectory,snapshot.receipt.backup),snapshot.bytes,TRANSACTION_FILE_MODE,options.trustedUid,options.trustedGid)}fsyncDirectory(options.backupDirectory);atomicWriteTrustedFile(options.manifestFile,canonicalManifest(manifest),TRANSACTION_FILE_MODE,options.trustedUid,options.trustedGid);fsyncDirectory(options.transactionDirectory);loadManifest(options)}catch(error){try{if(createdTransactionIdentity&&pathExistsNoFollow(options.transactionDirectory)){const current=import_node_fs2.default.lstatSync(options.transactionDirectory,{bigint:true});if(!current.isSymbolicLink()&¤t.isDirectory()&¤t.dev===createdTransactionIdentity.dev&¤t.ino===createdTransactionIdentity.ino&¤t.uid===createdTransactionIdentity.uid&¤t.gid===createdTransactionIdentity.gid){import_node_fs2.default.chmodSync(options.transactionDirectory,TRANSACTION_DIRECTORY_MODE);import_node_fs2.default.chownSync(options.transactionDirectory,options.trustedUid,options.trustedGid)}requireTrustedTransactionPath(options.transactionDirectory,TRANSACTION_DIRECTORY_MODE,options);import_node_fs2.default.rmSync(options.transactionDirectory,{force:true,recursive:true})}}catch{}throw error}return true}function ensureOriginalDirectories(receipts,expectedAgent,options){for(const receipt of receipts){if(receipt.state!=="directory")continue;const target=absoluteTarget(receipt.path,options);validateExistingAncestors(import_node_path2.default.join(target,".restore"),expectedAgent,options);let stat=null;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code!=="ENOENT"){fail4(`could not inspect restore directory ${target}`)}}if(stat&&(stat.isSymbolicLink()||!stat.isDirectory())){fail4(`restore directory is unsafe: ${target}`)}if(stat&&directoryMatchesReceipt(target,receipt))continue;if(!stat)import_node_fs2.default.mkdirSync(target,{mode:receipt.mode});import_node_fs2.default.chownSync(target,receipt.uid,receipt.gid);import_node_fs2.default.chmodSync(target,receipt.mode)}}function restoreFiles(receipts,backups,expectedAgent,options){for(const receipt of receipts){const target=absoluteTarget(receipt.path,options);validateExistingAncestors(target,expectedAgent,options);if(receipt.state==="absent"){let stat;try{stat=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")continue;fail4(`could not inspect new managed output ${target}`)}if(stat.isDirectory()){fail4(`new managed output unexpectedly became a directory: ${target}`)}import_node_fs2.default.unlinkSync(target);continue}if(fileMatchesReceipt(target,receipt))continue;const bytes=backups.get(receipt.path);if(!bytes)fail4(`verified transaction backup is missing: ${receipt.path}`);let current=null;try{current=import_node_fs2.default.lstatSync(target)}catch(error){if(error.code!=="ENOENT"){fail4(`could not inspect managed output before restore: ${target}`)}}if(current?.isDirectory()){fail4(`managed output unexpectedly became a directory: ${target}`)}atomicWriteTrustedFile(target,bytes,receipt.mode,receipt.uid,receipt.gid)}}function restoreDirectoryMetadata(receipts,options){for(const receipt of[...receipts].reverse()){const target=absoluteTarget(receipt.path,options);if(receipt.state==="absent"){try{import_node_fs2.default.rmdirSync(target)}catch(error){if(error.code==="ENOENT")continue;fail4(`could not remove newly created managed directory ${target}`)}continue}if(directoryMatchesReceipt(target,receipt))continue;const stat=import_node_fs2.default.lstatSync(target);if(stat.isSymbolicLink()||!stat.isDirectory()){fail4(`managed directory changed type during restore: ${target}`)}import_node_fs2.default.chownSync(target,receipt.uid,receipt.gid);import_node_fs2.default.chmodSync(target,receipt.mode)}}function verifyRestoration(manifest,options){for(const receipt of manifest.files){const target=absoluteTarget(receipt.path,options);if(receipt.state==="absent"){if(pathExistsNoFollow(target)){fail4(`new managed output remained after rollback: ${target}`)}continue}const stable=readStableFile(target,MAX_TRANSACTION_FILE_BYTES);if(stable.bytes.length!==receipt.size||(0,import_node_crypto5.createHash)("sha256").update(stable.bytes).digest("hex")!==receipt.sha256||Number(stable.stat.uid)!==receipt.uid||Number(stable.stat.gid)!==receipt.gid||Number(stable.stat.mode&0o7777n)!==receipt.mode){fail4(`managed output was not restored exactly: ${target}`)}}for(const receipt of manifest.directories){const target=absoluteTarget(receipt.path,options);if(receipt.state==="absent"){if(pathExistsNoFollow(target)){fail4(`new managed directory remained after rollback: ${target}`)}continue}const stat=import_node_fs2.default.lstatSync(target);if(stat.isSymbolicLink()||!stat.isDirectory()||stat.uid!==receipt.uid||stat.gid!==receipt.gid||modeOf2(stat)!==receipt.mode){fail4(`managed directory metadata was not restored exactly: ${target}`)}}}function rollbackManagedStartupSharedStateTransaction(expectedAgent,inputOptions={}){const options=resolveOptions(inputOptions);requireTransactionIdentity(options);const committed=loadCommitReceipt(options);if(committed){if(options.bootstrapIdentity===null){fail4("shared state is already durably committed")}assertCommitReceiptMatches(committed.receipt,{agent:expectedAgent,bootstrapIdentity:options.bootstrapIdentity});fail4("shared state is already durably committed and cannot be rolled back")}const manifest=loadManifest(options);if(!manifest)return false;if(manifest.agent!==expectedAgent){fail4(`pending transaction targets ${manifest.agent}, expected ${expectedAgent}`)}if(manifest.bootstrapIdentity!==options.bootstrapIdentity){fail4("pending transaction belongs to a different bootstrap attempt")}const backups=verifyAllBackups(manifest.files,options);ensureOriginalDirectories(manifest.directories,expectedAgent,options);restoreFiles(manifest.files,backups,expectedAgent,options);restoreDirectoryMetadata(manifest.directories,options);verifyRestoration(manifest,options);if(!options.readOnlyReceipt){removeTransactionDirectory(options)}return true}function commitManagedStartupSharedStateTransaction(expectedAgent,inputOptions={}){const options=resolveOptions(inputOptions);requireTransactionIdentity(options);if(options.readOnlyReceipt){fail4("cannot commit a read-only rollback receipt")}const committed=loadCommitReceipt(options);if(committed){if(options.bootstrapIdentity===null){fail4("durable commit receipt is missing its expected bootstrap identity")}assertCommitReceiptMatches(committed.receipt,{agent:expectedAgent,bootstrapIdentity:options.bootstrapIdentity});compactDurableCommitReceipt(committed,options);return true}const manifest=loadManifest(options);if(!manifest)return false;if(manifest.agent!==expectedAgent){fail4(`pending transaction targets ${manifest.agent}, expected ${expectedAgent}`)}if(manifest.bootstrapIdentity!==options.bootstrapIdentity){fail4("pending transaction belongs to a different bootstrap attempt")}if(manifest.bootstrapIdentity===null){removeTransactionDirectory(options);return true}verifyAllBackups(manifest.files,options);if(pathExistsNoFollow(options.commitReceiptDirectory)){fail4("durable commit receipt path appeared before transaction commit")}try{import_node_fs2.default.renameSync(options.transactionDirectory,options.commitReceiptDirectory);fsyncDirectory(options.transactionParentDirectory)}catch(error){fail4(`could not atomically establish durable commit state: ${error.message}`)}const renamed=loadCommitReceipt(options);if(!renamed)fail4("durable commit state disappeared after atomic rename");assertCommitReceiptMatches(renamed.receipt,{agent:expectedAgent,profileFingerprint:manifest.profileFingerprint,bootstrapIdentity:manifest.bootstrapIdentity});compactDurableCommitReceipt(renamed,options);return true}function clearManagedStartupSharedStateCommitReceipt(expectedAgent,inputOptions={}){const options=resolveOptions(inputOptions);requireTransactionIdentity(options);if(options.readOnlyReceipt){fail4("cannot clear a durable commit from a read-only receipt")}if(options.bootstrapIdentity===null){fail4("durable commit cleanup requires its bootstrap identity")}const committed=loadCommitReceipt(options);if(!committed)return false;assertCommitReceiptMatches(committed.receipt,{agent:expectedAgent,bootstrapIdentity:options.bootstrapIdentity});compactDurableCommitReceipt(committed,options);requireTrustedTransactionPath(options.commitReceiptDirectory,TRANSACTION_DIRECTORY_MODE,options);requireTrustedTransactionPath(options.commitReceiptFile,TRANSACTION_FILE_MODE,options);const entries=import_node_fs2.default.readdirSync(options.commitReceiptDirectory);if(entries.length!==1||entries[0]!==MANAGED_STARTUP_SHARED_COMMIT_RECEIPT_FILE){fail4("durable commit receipt directory contains unexpected artifacts")}import_node_fs2.default.rmSync(options.commitReceiptDirectory,{force:false,recursive:true});fsyncDirectory(options.transactionParentDirectory);if(pathExistsNoFollow(options.commitReceiptDirectory)){fail4("durable commit receipt remained after cleanup")}return true}function getManagedStartupSharedStateTransactionStatus(expected,inputOptions={}){const options=resolveOptions({...inputOptions,bootstrapIdentity:expected.bootstrapIdentity});requireTransactionIdentity(options);const manifest=loadManifest(options);if(manifest){if(manifest.agent!==expected.agent||manifest.profileFingerprint!==expected.profileFingerprint||manifest.bootstrapIdentity!==expected.bootstrapIdentity){fail4("pending transaction does not match the expected agent, profile fingerprint, or bootstrap identity")}verifyAllBackups(manifest.files,options);return"pending"}const committed=loadCommitReceipt(options);if(!committed)return"none";assertCommitReceiptMatches(committed.receipt,expected);return"committed"}var MANAGED_STARTUP_PROFILE_ENV="NEMOCLAW_STARTUP_PROFILE_B64";var MANAGED_STARTUP_CA_ENV="NEMOCLAW_CORPORATE_CA_B64";var MANAGED_STARTUP_RUNTIME_ENV_FILE="/run/nemoclaw/managed-startup-runtime.env";var MANAGED_STARTUP_RUNTIME_EXECUTABLE="/usr/local/lib/nemoclaw/managed-startup-image-runtime.cjs";var MANAGED_STARTUP_MERGED_CA_FILE="/run/nemoclaw/managed-startup-ca-bundle.pem";var MANAGED_STARTUP_COMPLETION_FILE="/run/nemoclaw/managed-startup-complete.json";var MANAGED_STARTUP_CORPORATE_CA_FILE="/usr/local/share/nemoclaw/corporate-ca.pem";var MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY="/usr/local/share/ca-certificates";var MANAGED_STARTUP_SYSTEM_CA_ANCHOR_RE=/^nemoclaw-corporate-ca-[0-9]{2}\.crt$/u;var SYSTEM_CA_BUNDLE_FILE="/etc/ssl/certs/ca-certificates.crt";var UPDATE_CA_CERTIFICATES_EXECUTABLE="/usr/sbin/update-ca-certificates";var MANAGED_STARTUP_TLS_ENV_NAMES=new Set(["CURL_CA_BUNDLE","GIT_SSL_CAINFO","NODE_EXTRA_CA_CERTS","REQUESTS_CA_BUNDLE","SSL_CERT_FILE"]);var MESSAGING_RUNTIME_PLAN_FILE="/usr/local/share/nemoclaw/messaging-runtime-plan.json";var ROOT_STATE_PARENT="/var/lib/nemoclaw";var ROOT_RUNTIME_DIRECTORY="/run/nemoclaw";var ROOT_OWNED_DIRECTORY_MODE=493;var MAX_TRUST_BUNDLE_BYTES=4*1024*1024;var HERMES_MANAGED_CONFIG_FILES=["/sandbox/.hermes/config.yaml","/sandbox/.hermes/.env"];var HERMES_GENERATED_MANAGED_POLICY_FILE="/sandbox/.hermes/managed-policy.json";var HERMES_INSTALLED_MANAGED_POLICY_FILE="/usr/local/share/nemoclaw/hermes-managed-policy.json";var MAX_HERMES_MANAGED_POLICY_BYTES=4*1024*1024;var FIXED_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";var SHA256_RE4=/^[a-f0-9]{64}$/u;var MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION=1;var MAX_MANAGED_STARTUP_COMPLETION_BYTES=4096;var MAX_MANAGED_STARTUP_RUNTIME_ENVIRONMENT_BYTES=512*1024;var ManagedStartupImageActionPlanError=class extends Error{constructor(message){super(`Cannot build managed startup image action plan: ${message}`);this.name="ManagedStartupImageActionPlanError"}};var ManagedStartupImageRuntimeError=class extends Error{constructor(message){super(`Managed startup image application failed: ${message}`);this.name="ManagedStartupImageRuntimeError"}};function failActionPlan(message){throw new ManagedStartupImageActionPlanError(message)}function exactActionPlanAgent(value){if(MANAGED_STARTUP_AGENTS.includes(value)){return value}return failActionPlan(`unsupported agent ${JSON.stringify(value)}`)}function fail5(message){throw new ManagedStartupImageRuntimeError(message)}function validateManagedStartupApplicationRuntimePlan(plan){if(typeof plan!=="object"||plan===null){return fail5("application runtime plan must be an object")}const exportEnvironment=plan.exportEnvironment;const unsetEnvironment=plan.unsetEnvironment;if(typeof exportEnvironment!=="object"||exportEnvironment===null||Array.isArray(exportEnvironment)||!Array.isArray(unsetEnvironment)){return fail5("application runtime plan must contain exports and unsets")}const exports2={};for(const[name,value]of Object.entries(exportEnvironment)){if(!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)){return fail5(`invalid application runtime environment key ${JSON.stringify(name)}`)}if(typeof value!=="string"||value.includes("\0")||/[\r\n]/u.test(value)){return fail5(`application runtime environment value for ${name} must be single-line text`)}exports2[name]=value}const unsets=new Set;for(const name of unsetEnvironment){if(typeof name!=="string"||!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)){return fail5(`invalid application runtime unset ${JSON.stringify(name)}`)}if(unsets.has(name)){return fail5(`duplicate application runtime unset ${name}`)}if(Object.hasOwn(exports2,name)){return fail5(`application runtime cannot both export and unset ${name}`)}unsets.add(name)}return Object.freeze({exportEnvironment:Object.freeze(Object.fromEntries(Object.entries(exports2).sort(([left],[right])=>left.localeCompare(right)))),unsetEnvironment:Object.freeze([...unsets].sort())})}function applyManagedStartupCommandEnvironmentPlan(environment,plan){const validated=validateManagedStartupApplicationRuntimePlan(plan);const applied={...environment};for(const name of[...Object.keys(validated.exportEnvironment),...validated.unsetEnvironment]){delete applied[name]}return applied}function exactAgent2(value){if(MANAGED_STARTUP_AGENTS.includes(value)){return value}return fail5(`unsupported agent ${JSON.stringify(value)}`)}function managedTransactionProfile(expectedAgentInput,env=process.env){requireRoot();const expectedAgent=exactAgent2(expectedAgentInput);if(env.NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION!=="1"){fail5("shared-state transactions require a complete managed image")}const encodedProfile=env[MANAGED_STARTUP_PROFILE_ENV];if(!encodedProfile)fail5(`${MANAGED_STARTUP_PROFILE_ENV} is required`);const profile=decodeManagedStartupProfile(encodedProfile);if(profile.agent!==expectedAgent){fail5(`shared-state transaction profile targets ${profile.agent}, expected ${expectedAgent}`)}return profile}function requireRoot(){if(process.geteuid?.()!==0){fail5("managed startup requires container effective uid 0")}}function modeOf3(stat){return stat.mode&511}function requireRootOwnedDirectory(target,mode){let stat;try{stat=import_node_fs3.default.lstatSync(target)}catch{fail5(`required root-owned directory is missing: ${target}`)}if(stat.isSymbolicLink()||!stat.isDirectory()||stat.uid!==0||stat.gid!==0||modeOf3(stat)!==mode){fail5(`${target} must be a root:root directory with mode ${mode.toString(8)}`)}}function ensureRootOwnedDirectory(target,mode=ROOT_OWNED_DIRECTORY_MODE){const parent=import_node_path3.default.dirname(target);const parentStat=import_node_fs3.default.lstatSync(parent);if(parentStat.isSymbolicLink()||!parentStat.isDirectory()||parentStat.uid!==0||parentStat.gid!==0||(modeOf3(parentStat)&18)!==0){fail5(`refusing unsafe parent directory for ${target}`)}try{import_node_fs3.default.mkdirSync(target,{mode});import_node_fs3.default.chownSync(target,0,0);import_node_fs3.default.chmodSync(target,mode)}catch(error){if(error.code!=="EEXIST"){fail5(`could not create ${target}`)}}requireRootOwnedDirectory(target,mode)}function requireSafeExistingRootTarget(target){let stat;try{stat=import_node_fs3.default.lstatSync(target)}catch(error){if(error.code==="ENOENT")return;fail5(`could not inspect ${target}`)}if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1||stat.uid!==0||stat.gid!==0){fail5(`refusing to replace unsafe root-owned file ${target}`)}}function atomicWriteRootFile(target,contents,mode){const parent=import_node_path3.default.dirname(target);const parentStat=import_node_fs3.default.lstatSync(parent);if(parentStat.isSymbolicLink()||!parentStat.isDirectory()||parentStat.uid!==0||parentStat.gid!==0||(modeOf3(parentStat)&18)!==0){fail5(`refusing unsafe root-owned file parent ${parent}`)}requireSafeExistingRootTarget(target);const temporary=import_node_path3.default.join(parent,`.${import_node_path3.default.basename(target)}.${(0,import_node_crypto6.randomBytes)(12).toString("hex")}`);let descriptor;try{descriptor=import_node_fs3.default.openSync(temporary,import_node_fs3.default.constants.O_CREAT|import_node_fs3.default.constants.O_EXCL|import_node_fs3.default.constants.O_WRONLY|import_node_fs3.default.constants.O_NOFOLLOW,384);import_node_fs3.default.fchownSync(descriptor,0,0);import_node_fs3.default.writeFileSync(descriptor,contents);import_node_fs3.default.fchmodSync(descriptor,mode);import_node_fs3.default.fsyncSync(descriptor);import_node_fs3.default.closeSync(descriptor);descriptor=void 0;import_node_fs3.default.renameSync(temporary,target)}catch(error){if(descriptor!==void 0)import_node_fs3.default.closeSync(descriptor);try{import_node_fs3.default.unlinkSync(temporary)}catch{}fail5(`could not atomically write ${target}: ${error.message}`)}const stat=import_node_fs3.default.lstatSync(target);if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1||stat.uid!==0||stat.gid!==0||modeOf3(stat)!==mode){fail5(`root-owned output failed metadata verification: ${target}`)}}function removeSafeRootFile(target){requireSafeExistingRootTarget(target);try{import_node_fs3.default.unlinkSync(target)}catch(error){if(error.code!=="ENOENT"){fail5(`could not remove ${target}`)}}}function trustedExecutable(target){try{const stat=import_node_fs3.default.lstatSync(target);return!stat.isSymbolicLink()&&stat.isFile()&&stat.uid===0&&stat.gid===0&&(modeOf3(stat)&18)===0&&(modeOf3(stat)&73)!==0}catch{return false}}function readSandboxIdentity(){const readId=flag=>{const result=(0,import_node_child_process.spawnSync)("/usr/bin/id",[flag,"sandbox"],{encoding:"utf8",env:{PATH:FIXED_PATH}});const value=result.stdout.trim();if(result.status!==0||!/^[1-9][0-9]*$/u.test(value)){fail5("could not resolve the sandbox account")}return value};return{uid:readId("-u"),gid:readId("-g")}}function managedStartupSandboxPrefix(){if(trustedExecutable("/usr/bin/setpriv")){const identity=readSandboxIdentity();return["/usr/bin/setpriv",`--reuid=${identity.uid}`,`--regid=${identity.gid}`,"--init-groups","--"]}return fail5("a trusted setpriv executable is required")}function commandEnvironment(configurationEnvironment,applicationRuntime){const env=applyManagedStartupCommandEnvironmentPlan({...process.env,...configurationEnvironment,HOME:"/sandbox",PATH:FIXED_PATH,NPM_CONFIG_OFFLINE:"true",npm_config_offline:"true",PIP_DISABLE_PIP_VERSION_CHECK:"1",PIP_NO_INDEX:"1",UV_OFFLINE:"1"},applicationRuntime);delete env[MANAGED_STARTUP_PROFILE_ENV];delete env[MANAGED_STARTUP_CA_ENV];return env}function execute(argv,runAs,configurationEnvironment,applicationRuntime,capture=false){if(argv.length===0)fail5("refusing an empty managed startup command");const command=runAs==="sandbox"?[...managedStartupSandboxPrefix(),...argv]:[...argv];const result=(0,import_node_child_process.spawnSync)(command[0],command.slice(1),{encoding:"utf8",env:commandEnvironment(configurationEnvironment,applicationRuntime),stdio:capture?"pipe":"inherit"});if(result.error){fail5(`could not execute ${argv[0]}: ${result.error.message}`)}if(result.status!==0){const detail=capture?`: ${(result.stderr||result.stdout).trim()}`:"";fail5(`${argv[0]} exited with status ${String(result.status??"unknown")}${detail}`)}return{status:result.status,stdout:result.stdout??"",stderr:result.stderr??""}}function generatorCommand(agent){switch(agent){case"openclaw":return["/usr/local/bin/node","--experimental-strip-types","/scripts/generate-openclaw-config.mts"];case"hermes":return["/usr/local/bin/node","--experimental-strip-types","/opt/nemoclaw-hermes-config/generate-config.ts"];case"langchain-deepagents-code":return["/usr/local/bin/node","--experimental-strip-types","/opt/nemoclaw-deepagents-code/generate-config.ts"];case"pi":return["/usr/local/bin/node","--experimental-strip-types","/opt/nemoclaw-pi/generate-config.ts"]}}function messagingCommand(agent,phase,mode){return["/usr/local/bin/node","--experimental-strip-types","/src/lib/messaging/applier/build/messaging-build-applier.mts","--agent",agent,"--phase",phase,"--mode",mode,...phase==="post-agent-install"?["--managed-startup-runtime"]:[]]}function assertActionAgent(inputAgent,actionAgent){if(inputAgent!==actionAgent){failActionPlan(`action for ${actionAgent} cannot be used by ${inputAgent}`)}}function buildManagedStartupImageActionPlan(input){const inputAgent=exactActionPlanAgent(input.agent);const commands=[];let dashboardActions=0;let generateActions=0;let runtimeMessagingActions=0;let postMessagingActions=0;for(const action of input.actions){switch(action.kind){case"configure-dashboard":{if(action.dashboard.agent!==input.agent){failActionPlan(`dashboard for ${action.dashboard.agent} cannot be used by ${input.agent}`)}dashboardActions+=1;break}case"generate-agent-config":{assertActionAgent(inputAgent,exactActionPlanAgent(action.agent));if(action.runAs!=="sandbox"){failActionPlan("agent configuration generation must run as sandbox")}generateActions+=1;commands.push({action:"generate-agent-config",runAs:action.runAs,argv:generatorCommand(action.agent)});break}case"apply-messaging-plan":{assertActionAgent(inputAgent,exactActionPlanAgent(action.agent));if(action.mode!=="apply"&&action.mode!=="clear"){failActionPlan("messaging intent must be apply or clear")}if(action.phase==="runtime-setup"){if(action.runAs!=="root"){failActionPlan("messaging runtime setup must run as root")}runtimeMessagingActions+=1;commands.push({action:"messaging-runtime-setup",runAs:action.runAs,argv:messagingCommand(action.agent,action.phase,action.mode)})}else if(action.phase==="post-agent-install"){if(action.runAs!=="sandbox"){failActionPlan("messaging post-agent configuration must run as sandbox")}postMessagingActions+=1;commands.push({action:"messaging-post-agent-install",runAs:action.runAs,argv:messagingCommand(action.agent,action.phase,action.mode)})}else{failActionPlan("unsupported messaging construction phase")}break}default:failActionPlan("unsupported managed startup construction action")}}if(dashboardActions!==1){failActionPlan("exactly one dashboard construction action is required")}if(generateActions!==1){failActionPlan("exactly one agent config construction action is required")}const supportsMessaging=MANAGED_STARTUP_MESSAGING_AGENTS.includes(inputAgent);const expectedMessagingActions=supportsMessaging?1:0;if(runtimeMessagingActions!==expectedMessagingActions||postMessagingActions!==expectedMessagingActions){failActionPlan(`${inputAgent} requires ${String(expectedMessagingActions)} action for each messaging phase`)}const expectedOrder=supportsMessaging?["messaging-runtime-setup","generate-agent-config","messaging-post-agent-install"]:["generate-agent-config"];if(commands.some((command,index)=>command.action!==expectedOrder[index])){failActionPlan(`${inputAgent} image actions are not in the required construction order`)}return Object.freeze(commands.map(command=>Object.freeze({...command,argv:Object.freeze([...command.argv])})))}function prepareMessagingRuntimeTarget(mode){if(mode==="clear"){removeSafeRootFile(MESSAGING_RUNTIME_PLAN_FILE);return}requireSafeExistingRootTarget(MESSAGING_RUNTIME_PLAN_FILE);try{import_node_fs3.default.unlinkSync(MESSAGING_RUNTIME_PLAN_FILE)}catch(error){if(error.code!=="ENOENT"){fail5("could not prepare the messaging runtime-plan target")}}}function verifyMessagingRuntimeTarget(mode){if(mode==="clear"){if(import_node_fs3.default.existsSync(MESSAGING_RUNTIME_PLAN_FILE)){fail5("clear messaging profile left a runtime-plan artifact")}return}const stat=import_node_fs3.default.lstatSync(MESSAGING_RUNTIME_PLAN_FILE);if(stat.isSymbolicLink()||!stat.isFile()||stat.nlink!==1||stat.uid!==0||stat.gid!==0||modeOf3(stat)!==420){fail5("messaging runtime-plan artifact failed root ownership validation")}}function runInternalSandboxAction(action,configurationEnvironment,applicationRuntime,extraEnvironment={}){execute(["/usr/local/bin/node",MANAGED_STARTUP_RUNTIME_EXECUTABLE,`--internal-${action}`],"sandbox",{...configurationEnvironment,...extraEnvironment},applicationRuntime)}function sealOpenClawConfiguration(configurationEnvironment,applicationRuntime){const validation=execute(["/usr/local/bin/openclaw","config","validate","--json"],"sandbox",{...configurationEnvironment,OPENCLAW_CONFIG_PATH:"/sandbox/.openclaw/openclaw.json"},applicationRuntime,true);let parsed;try{parsed=JSON.parse(validation.stdout)}catch{fail5("OpenClaw config validation did not emit JSON")}if(typeof parsed!=="object"||parsed===null||parsed.valid!==true){fail5("OpenClaw rejected the generated managed startup config")}runInternalSandboxAction("write-openclaw-hash",configurationEnvironment,applicationRuntime)}function sameStableFileMetadata(left,right){return left.dev===right.dev&&left.ino===right.ino&&left.mode===right.mode&&left.nlink===right.nlink&&left.uid===right.uid&&left.gid===right.gid&&left.size===right.size&&left.mtimeNs===right.mtimeNs&&left.ctimeNs===right.ctimeNs}function readStableRegularFileSnapshot(target,maxBytes){if(typeof import_node_fs3.default.constants.O_NOFOLLOW!=="number"){fail5("O_NOFOLLOW is unavailable for managed startup file reads")}const nonblock=typeof import_node_fs3.default.constants.O_NONBLOCK==="number"?import_node_fs3.default.constants.O_NONBLOCK:0;let descriptor;try{descriptor=import_node_fs3.default.openSync(target,import_node_fs3.default.constants.O_RDONLY|import_node_fs3.default.constants.O_NOFOLLOW|nonblock)}catch(error){if(error.code==="ENOENT")throw error;fail5(`refusing unsafe or unreadable file ${target}`)}try{const before=import_node_fs3.default.fstatSync(descriptor,{bigint:true});if(!before.isFile()||before.nlink!==1n||before.size<1n||before.size>BigInt(maxBytes)){fail5(`refusing unsafe or oversized file ${target}`)}const bytes=Buffer.alloc(Number(before.size));let offset=0;while(offset`${block.trim()} +`)}function managedSystemCaAnchorNames(){try{import_node_fs3.default.lstatSync(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY)}catch(error){if(error.code==="ENOENT")return[];fail5("could not inspect the managed system CA anchor directory")}requireRootOwnedDirectory(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY,ROOT_OWNED_DIRECTORY_MODE);try{return import_node_fs3.default.readdirSync(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY).filter(name=>MANAGED_STARTUP_SYSTEM_CA_ANCHOR_RE.test(name)).sort()}catch(error){fail5("could not inspect the managed system CA anchors")}}function refreshSystemCaBundle(){if(!trustedExecutable(UPDATE_CA_CERTIFICATES_EXECUTABLE)){fail5(`a trusted ${UPDATE_CA_CERTIFICATES_EXECUTABLE} executable is required`)}const result=(0,import_node_child_process.spawnSync)(UPDATE_CA_CERTIFICATES_EXECUTABLE,[],{encoding:"utf8",env:{PATH:FIXED_PATH},stdio:"inherit"});if(result.error){fail5(`could not execute ${UPDATE_CA_CERTIFICATES_EXECUTABLE}: ${result.error.message}`)}if(result.status!==0){fail5(`${UPDATE_CA_CERTIFICATES_EXECUTABLE} exited with status ${String(result.status??"unknown")}`)}}function requireSystemCaBundleContains(blocks){const systemBundle=safeTrustBundle(SYSTEM_CA_BUNDLE_FILE);if(systemBundle===null)fail5("the refreshed system CA bundle is missing");const systemBlocks=systemBundle.toString("utf8").match(PEM_CERTIFICATE_RE_GLOBAL)??[];const systemFingerprints=new Set;for(const block of systemBlocks){try{systemFingerprints.add(new import_node_crypto6.X509Certificate(block).fingerprint256)}catch{fail5("the refreshed system CA bundle contains an invalid certificate")}}for(const block of blocks){if(!systemFingerprints.has(new import_node_crypto6.X509Certificate(block).fingerprint256)){fail5("the refreshed system CA bundle does not contain the corporate CA")}}}function installCorporateCaSystemAnchors(corporateCaPath){const existingNames=managedSystemCaAnchorNames();if(corporateCaPath===null){for(const name of existingNames){removeSafeRootFile(import_node_path3.default.join(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY,name))}refreshSystemCaBundle();return}ensureRootOwnedDirectory(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY);const blocks=corporateCaCertificateBlocks(corporateCaPath);const expectedNames=blocks.map((_block,index)=>`nemoclaw-corporate-ca-${String(index+1).padStart(2,"0")}.crt`);for(const name of existingNames){if(!expectedNames.includes(name)){removeSafeRootFile(import_node_path3.default.join(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY,name))}}for(const[index,name]of expectedNames.entries()){atomicWriteRootFile(import_node_path3.default.join(MANAGED_STARTUP_SYSTEM_CA_ANCHOR_DIRECTORY,name),blocks[index],292)}refreshSystemCaBundle();requireSystemCaBundleContains(blocks)}function safeTrustBundle(target){try{const{bytes,stat}=readStableRegularFileSnapshot(target,MAX_TRUST_BUNDLE_BYTES);if(Number(stat.mode&0o022n)!==0){fail5(`refusing unsafe trust bundle ${target}`)}return bytes}catch(error){if(error.code==="ENOENT")return null;throw error}}function mergeCorporateCa(corporateCaPath){if(corporateCaPath===null){removeSafeRootFile(MANAGED_STARTUP_MERGED_CA_FILE);return false}const corporate=readStableRegularFile(corporateCaPath,128*1024);const candidates=["/etc/openshell-tls/ca-bundle.pem",process.env.SSL_CERT_FILE??"","/etc/ssl/certs/ca-certificates.crt"].filter((candidate,index,values)=>candidate&&candidate!==MANAGED_STARTUP_MERGED_CA_FILE&&values.indexOf(candidate)===index);let base=null;for(const candidate of candidates){base=safeTrustBundle(candidate);if(base)break}const merged=Buffer.concat([...base?[base,Buffer.from("\n","utf8")]:[],corporate,...corporate.at(-1)===10?[]:[Buffer.from("\n","utf8")]]);atomicWriteRootFile(MANAGED_STARTUP_MERGED_CA_FILE,merged,292);return true}function shellSingleQuote(value){if(value.includes("\0")||/[\r\n]/u.test(value)){fail5("runtime environment values must be single-line text")}return`'${value.replaceAll("'",`'"'"'`)}'`}function serializeManagedStartupRuntimeEnvironment(environment,corporateCaMerged,configurationEnvironment={},applicationRuntime={exportEnvironment:{},unsetEnvironment:[]}){const{output,unsetNames}=materializeManagedStartupRuntimeEnvironment(environment,corporateCaMerged,configurationEnvironment,applicationRuntime);const unsetLines=unsetNames.map(name=>`unset ${name}`);const exportLines=Object.entries(output).sort(([left],[right])=>left.localeCompare(right)).map(([name,value])=>`export ${name}=${shellSingleQuote(value)}`);return`${[...unsetLines,...exportLines].join("\n")} +`}function materializeManagedStartupRuntimeEnvironment(environment,corporateCaMerged,configurationEnvironment={},applicationRuntime={exportEnvironment:{},unsetEnvironment:[]}){const validatedApplicationRuntime=validateManagedStartupApplicationRuntimePlan(applicationRuntime);const output={...environment,...validatedApplicationRuntime.exportEnvironment,NEMOCLAW_MANAGED_STARTUP_APPLIED:"1"};if(corporateCaMerged){for(const name of MANAGED_STARTUP_TLS_ENV_NAMES){delete output[name]}output._NEMOCLAW_CORPORATE_CA_MERGED="1"}for(const name of[...Object.keys(configurationEnvironment),...Object.keys(output)]){if(!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)){fail5(`invalid runtime environment key ${JSON.stringify(name)}`)}}const unsetNames=new Set([...Object.keys(configurationEnvironment).filter(name=>!Object.hasOwn(output,name)&&(!corporateCaMerged||!MANAGED_STARTUP_TLS_ENV_NAMES.has(name))),...validatedApplicationRuntime.unsetEnvironment.filter(name=>!corporateCaMerged||!MANAGED_STARTUP_TLS_ENV_NAMES.has(name))]);for(const name of validatedApplicationRuntime.unsetEnvironment){if(Object.hasOwn(output,name)){fail5(`runtime environment cannot both export and unset ${name}`)}}for(const name of unsetNames){if(!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)){fail5(`invalid runtime environment key ${JSON.stringify(name)}`)}}return{output,unsetNames:[...unsetNames].sort()}}function serializeManagedStartupCompletionMarker(marker){if(marker.schemaVersion!==MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION||!MANAGED_STARTUP_AGENTS.includes(marker.agent)||!SHA256_RE4.test(marker.profileFingerprint)||!SHA256_RE4.test(marker.runtimeEnvironmentSha256)||typeof marker.corporateCaMerged!=="boolean"){fail5("managed startup completion marker is invalid")}return`${JSON.stringify({agent:marker.agent,corporateCaMerged:marker.corporateCaMerged,profileFingerprint:marker.profileFingerprint,runtimeEnvironmentSha256:marker.runtimeEnvironmentSha256,schemaVersion:marker.schemaVersion})} +`}function parseManagedStartupCompletionMarker(text){let parsed;try{parsed=JSON.parse(text)}catch{fail5("managed startup completion marker is not valid JSON")}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail5("managed startup completion marker must be an object")}const record=parsed;const expectedKeys=["agent","corporateCaMerged","profileFingerprint","runtimeEnvironmentSha256","schemaVersion"];if(Object.keys(record).sort().join(",")!==expectedKeys.sort().join(",")||record.schemaVersion!==MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION||typeof record.agent!=="string"||!MANAGED_STARTUP_AGENTS.includes(record.agent)||typeof record.profileFingerprint!=="string"||!SHA256_RE4.test(record.profileFingerprint)||typeof record.runtimeEnvironmentSha256!=="string"||!SHA256_RE4.test(record.runtimeEnvironmentSha256)||typeof record.corporateCaMerged!=="boolean"){fail5("managed startup completion marker has an invalid schema")}const marker={schemaVersion:MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION,agent:record.agent,profileFingerprint:record.profileFingerprint,runtimeEnvironmentSha256:record.runtimeEnvironmentSha256,corporateCaMerged:record.corporateCaMerged};if(serializeManagedStartupCompletionMarker(marker)!==text){fail5("managed startup completion marker is not canonical")}return marker}function verifyManagedStartupImageCompletion(expectedAgentInput,expectedFingerprint,completionFile=MANAGED_STARTUP_COMPLETION_FILE,runtimeEnvironmentFile=MANAGED_STARTUP_RUNTIME_ENV_FILE){const expectedAgent=exactAgent2(expectedAgentInput);if(!SHA256_RE4.test(expectedFingerprint)){fail5("startup completion expected profile fingerprint is invalid")}const{bytes,stat}=readStableRegularFileSnapshot(completionFile,MAX_MANAGED_STARTUP_COMPLETION_BYTES);if(stat.nlink!==1n||stat.uid!==0n||stat.gid!==0n||Number(stat.mode&0o777n)!==292){fail5("managed startup completion marker must be root:root mode 0444")}const marker=parseManagedStartupCompletionMarker(bytes.toString("utf8"));if(marker.agent!==expectedAgent||marker.profileFingerprint!==expectedFingerprint){fail5("managed startup completion marker does not match the requested profile")}const runtimeEnvironment=readStableRegularFileSnapshot(runtimeEnvironmentFile,MAX_MANAGED_STARTUP_RUNTIME_ENVIRONMENT_BYTES);if(runtimeEnvironment.stat.nlink!==1n||runtimeEnvironment.stat.uid!==0n||runtimeEnvironment.stat.gid!==0n||Number(runtimeEnvironment.stat.mode&0o777n)!==292){fail5("managed startup runtime environment must be root:root mode 0444")}const runtimeEnvironmentSha256=(0,import_node_crypto6.createHash)("sha256").update(runtimeEnvironment.bytes).digest("hex");if(runtimeEnvironmentSha256!==marker.runtimeEnvironmentSha256){fail5("managed startup completion marker runtime environment digest mismatch")}return{agent:expectedAgent,fingerprint:expectedFingerprint}}function waitForManagedStartupImageCompletion(expectedAgentInput,expectedFingerprint,timeoutSeconds=600){if(!Number.isSafeInteger(timeoutSeconds)||timeoutSeconds<1||timeoutSeconds>3600){fail5("startup completion wait timeout must be an integer from 1 to 3600 seconds")}const deadline=Date.now()+timeoutSeconds*1e3;while(true){try{return verifyManagedStartupImageCompletion(expectedAgentInput,expectedFingerprint)}catch(error){if(error.code!=="ENOENT")throw error;if(Date.now()>=deadline){fail5(`startup completion was not published within ${String(timeoutSeconds)} seconds`)}Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,250)}}}function applyAdapter(context,mapped){if(mapped.agent!==context.agent){fail5(`mapped ${mapped.agent} environment for ${context.agent}`)}const commandPlan=buildManagedStartupImageActionPlan({agent:mapped.agent,actions:mapped.actions});let commandIndex=0;for(const action of mapped.actions){if(action.kind==="configure-dashboard")continue;const command=commandPlan[commandIndex];if(!command)fail5(`missing image command for ${action.kind}`);commandIndex+=1;if(action.kind==="apply-messaging-plan"){if(action.phase==="runtime-setup"){prepareMessagingRuntimeTarget(action.mode)}execute(command.argv,command.runAs,mapped.configurationEnvironment,mapped.applicationRuntime);if(action.phase==="runtime-setup"){verifyMessagingRuntimeTarget(action.mode)}continue}execute(command.argv,command.runAs,mapped.configurationEnvironment,mapped.applicationRuntime)}if(commandIndex!==commandPlan.length){fail5("image action plan contains an unmatched command")}switch(context.agent){case"openclaw":sealOpenClawConfiguration(mapped.configurationEnvironment,mapped.applicationRuntime);break;case"hermes":installHermesManagedPolicy();sealHermesConfiguration(mapped.configurationEnvironment,mapped.applicationRuntime);normalizeHermesManagedConfiguration();break;case"langchain-deepagents-code":break}installRootOwnedMaterials(mapped.materials);installCorporateCa(context.corporateCaPath);installCorporateCaSystemAnchors(context.corporateCaPath);mergeCorporateCa(context.corporateCaPath)}function adapters(mapped){return MANAGED_STARTUP_AGENTS.map(agent=>({agent,apply:context=>applyAdapter(context,mapped)}))}async function applyManagedStartupImageProfile(expectedAgentInput,env=process.env){requireRoot();const expectedAgent=exactAgent2(expectedAgentInput);if(env.NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION!=="1"){fail5("startup profiles require a complete managed image")}const encodedProfile=env[MANAGED_STARTUP_PROFILE_ENV];if(!encodedProfile)fail5(`${MANAGED_STARTUP_PROFILE_ENV} is required`);let profile;try{profile=decodeManagedStartupProfile(encodedProfile)}catch(error){fail5(error.message)}if(profile.agent!==expectedAgent){fail5(`managed startup profile targets ${profile.agent}, expected ${expectedAgent}`)}const mapped=mapManagedStartupProfileToAgentEnvironment(profile,env);validateManagedStartupApplicationRuntimePlan(mapped.applicationRuntime);ensureRootOwnedDirectory(ROOT_STATE_PARENT);ensureRootOwnedDirectory(ROOT_RUNTIME_DIRECTORY);const result=await coordinateManagedStartupApplication({encodedProfile,expectedAgent,...env[MANAGED_STARTUP_CA_ENV]===void 0?{}:{corporateCaB64:env[MANAGED_STARTUP_CA_ENV]}},adapters(mapped));if(mapped.agent!==result.application.profile.agent){fail5(`mapped ${mapped.agent} environment for ${result.application.profile.agent}`)}if(expectedAgent==="hermes"&&!result.adapterApplied){normalizeHermesManagedConfiguration()}let corporateCaMerged;if(result.adapterApplied){corporateCaMerged=result.application.corporateCaPath!==null}else{verifyRootOwnedMaterials(mapped.materials);if(result.application.corporateCaPath===null){if(import_node_fs3.default.existsSync(MANAGED_STARTUP_CORPORATE_CA_FILE)){fail5("committed profile without a corporate CA has a stale CA material")}}else{const expected=readStableRegularFile(result.application.corporateCaPath,128*1024);const installed=readStableRegularFile(MANAGED_STARTUP_CORPORATE_CA_FILE,128*1024);if(!expected.equals(installed)){fail5("committed corporate CA material drifted")}}installCorporateCaSystemAnchors(result.application.corporateCaPath);corporateCaMerged=mergeCorporateCa(result.application.corporateCaPath)}const runtimeEnvironment=serializeManagedStartupRuntimeEnvironment(mapped.runtimeEnvironment,corporateCaMerged,mapped.configurationEnvironment,mapped.applicationRuntime);atomicWriteRootFile(MANAGED_STARTUP_RUNTIME_ENV_FILE,runtimeEnvironment,292);atomicWriteRootFile(MANAGED_STARTUP_COMPLETION_FILE,serializeManagedStartupCompletionMarker({schemaVersion:MANAGED_STARTUP_COMPLETION_SCHEMA_VERSION,agent:expectedAgent,profileFingerprint:result.application.fingerprint,runtimeEnvironmentSha256:(0,import_node_crypto6.createHash)("sha256").update(runtimeEnvironment,"utf8").digest("hex"),corporateCaMerged}),292);return{agent:expectedAgent,adapterApplied:result.adapterApplied,fingerprint:result.application.fingerprint,runtimeEnvironmentFile:MANAGED_STARTUP_RUNTIME_ENV_FILE}}function completionAlreadyPublished(request){try{verifyManagedStartupImageCompletion(request.agent,request.profileFingerprint);return true}catch(error){if(error.code==="ENOENT")return false;throw error}}async function applyManagedStartupRootRequest(request,env=process.env,options={}){requireRoot();const profile=decodeManagedStartupProfile(request.encodedProfile);if(profile.agent!==request.agent||fingerprintManagedStartupProfile(profile)!==request.profileFingerprint){fail5("root application request identity does not match its profile")}const imageEnvironment={HOME:"/root",PATH:FIXED_PATH,NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION:"1",...selectManagedStartupApplicationRuntimeEnvironment(env),[MANAGED_STARTUP_PROFILE_ENV]:request.encodedProfile,...request.corporateCaB64===null?{}:{[MANAGED_STARTUP_CA_ENV]:request.corporateCaB64}};mapManagedStartupProfileToAgentEnvironment(profile,imageEnvironment);const alreadyPublished=completionAlreadyPublished(request);const bootstrapIdentity=options.bootstrapIdentity??null;const transactionStatus=alreadyPublished&&bootstrapIdentity!==null?getManagedStartupSharedStateTransactionStatus({agent:request.agent,profileFingerprint:request.profileFingerprint,bootstrapIdentity}):null;if(transactionStatus==="none"){fail5("completed startup profile has no shared-state authority for this bootstrap attempt")}if(!alreadyPublished){ensureRootOwnedDirectory(ROOT_STATE_PARENT);beginManagedStartupSharedStateTransaction(profile,{bootstrapIdentity})}const result=await applyManagedStartupImageProfile(request.agent,imageEnvironment);return{...result,transactionPending:!alreadyPublished||transactionStatus==="pending"}}function readBoundedRootApplyStdin(){const chunks=[];let total=0;while(true){const chunk=Buffer.alloc(16*1024);const read=import_node_fs3.default.readSync(0,chunk,0,chunk.length,null);if(read===0)break;total+=read;if(total>MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES){fail5("root application stdin exceeds its bounded transport")}chunks.push(chunk.subarray(0,read))}const bytes=Buffer.concat(chunks,total);const text=bytes.toString("utf8");if(text.includes("\0")||!Buffer.from(text,"utf8").equals(bytes)){fail5("root application stdin must be valid UTF-8 without NUL bytes")}return text}function writeSandboxFileAtomically(target,contents,mode){const parent=import_node_path3.default.dirname(target);const parentStat=import_node_fs3.default.lstatSync(parent);if(parentStat.isSymbolicLink()||!parentStat.isDirectory()||parentStat.uid!==process.geteuid?.()||parentStat.gid!==process.getegid?.()){fail5(`refusing unsafe sandbox-owned directory ${parent}`)}const temporary=import_node_path3.default.join(parent,`.${import_node_path3.default.basename(target)}.${(0,import_node_crypto6.randomBytes)(12).toString("hex")}`);let descriptor;try{descriptor=import_node_fs3.default.openSync(temporary,import_node_fs3.default.constants.O_CREAT|import_node_fs3.default.constants.O_EXCL|import_node_fs3.default.constants.O_WRONLY|import_node_fs3.default.constants.O_NOFOLLOW,384);import_node_fs3.default.writeFileSync(descriptor,contents);import_node_fs3.default.fchmodSync(descriptor,mode);import_node_fs3.default.fsyncSync(descriptor);import_node_fs3.default.closeSync(descriptor);descriptor=void 0;import_node_fs3.default.renameSync(temporary,target)}catch(error){if(descriptor!==void 0)import_node_fs3.default.closeSync(descriptor);try{import_node_fs3.default.unlinkSync(temporary)}catch{}fail5(`could not write sandbox-owned file ${target}: ${error.message}`)}}function internalWriteOpenClawHash(){if(process.geteuid?.()===0)fail5("sandbox hash writer must not run as root");const configPath="/sandbox/.openclaw/openclaw.json";const config=readStableRegularFile(configPath,16*1024*1024);const text=`${(0,import_node_crypto6.createHash)("sha256").update(config).digest("hex")} openclaw.json `;writeSandboxFileAtomically("/sandbox/.openclaw/.config-hash",text,432)}function internalWriteHermesCompatHash(){if(process.geteuid?.()===0)fail5("sandbox hash writer must not run as root");const encoded=process.env.NEMOCLAW_MANAGED_HERMES_HASH_B64??"";if(encoded.length===0||encoded.length>4096||!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded)){fail5("Hermes compatibility hash transport is invalid")}const decoded=Buffer.from(encoded,"base64");if(decoded.toString("base64")!==encoded){fail5("Hermes compatibility hash transport is non-canonical")}writeSandboxFileAtomically("/sandbox/.hermes/.config-hash",decoded.toString("utf8"),416)}function readCliAgent(argv,expectedLength=2){const index=argv.indexOf("--agent");if(index<0||index+1>=argv.length||argv.length!==expectedLength){fail5("usage: managed-startup-image-runtime [--apply-root-stdin|--wait-for-completion|--verify-completion|--begin-shared-state-transaction|--commit-shared-state-transaction|--clear-shared-state-commit-receipt|--shared-state-transaction-status] --agent ")}return argv[index+1]}function readCliFingerprint(argv){const index=argv.indexOf("--profile-fingerprint");if(index<0||index+1>=argv.length){fail5("managed startup profile fingerprint argument is missing")}return argv[index+1]}function readCliBootstrapIdentity(argv){const index=argv.indexOf("--bootstrap-identity");if(index<0||index+1>=argv.length||!SHA256_RE4.test(String(argv[index+1]??""))){fail5("managed bootstrap identity argument is missing or invalid")}return argv[index+1]}async function main(argv=process.argv.slice(2)){if(argv.length===1&&argv[0]==="--internal-write-openclaw-hash"){internalWriteOpenClawHash();return}if(argv.length===1&&argv[0]==="--internal-write-hermes-compat-hash"){internalWriteHermesCompatHash();return}if(argv.length===3&&argv[0]==="--apply-root-stdin"){const expectedAgent=exactAgent2(readCliAgent(argv,3));const request=parseManagedStartupRootApplyRequest(readBoundedRootApplyStdin());if(request.agent!==expectedAgent){fail5(`root application request targets ${request.agent}, expected ${expectedAgent}`)}const result2=await applyManagedStartupRootRequest(request);console.log(result2.transactionPending?`[managed-startup] applied ${result2.agent} profile ${result2.fingerprint}; transaction pending`:`[managed-startup] ${result2.agent} profile ${result2.fingerprint} was already complete`);return}if(argv.length===5&&(argv[0]==="--verify-completion"||argv[0]==="--wait-for-completion")){const agent=readCliAgent(argv,5);const fingerprint=readCliFingerprint(argv);const result2=argv[0]==="--wait-for-completion"?waitForManagedStartupImageCompletion(agent,fingerprint):verifyManagedStartupImageCompletion(agent,fingerprint);console.log(`[managed-startup] verified ${result2.agent} profile ${result2.fingerprint} completion`);return}if(argv.length===3&&argv[0]==="--begin-shared-state-transaction"){const profile=managedTransactionProfile(readCliAgent(argv,3));ensureRootOwnedDirectory(ROOT_STATE_PARENT);const created=beginManagedStartupSharedStateTransaction(profile);process.stdout.write(created?"created\n":"pending\n");return}if((argv.length===4||argv.length===6)&&argv[0]==="--rollback-shared-state-transaction"&&argv[argv.length-1]==="--read-only-receipt"){requireRoot();const agent=exactAgent2(readCliAgent(argv,argv.length));const rolledBack=rollbackManagedStartupSharedStateTransaction(agent,{transactionDirectory:MANAGED_STARTUP_SHARED_ROLLBACK_RECEIPT_DIRECTORY,readOnlyReceipt:true,bootstrapIdentity:argv.length===6?readCliBootstrapIdentity(argv):null});if(!rolledBack)fail5("read-only shared-state rollback receipt is missing");console.log(`[managed-startup] verified and restored ${agent} shared state`);return}if((argv.length===3||argv.length===5)&&argv[0]==="--commit-shared-state-transaction"){requireRoot();const agent=exactAgent2(readCliAgent(argv,argv.length));if(!commitManagedStartupSharedStateTransaction(agent,{bootstrapIdentity:argv.length===5?readCliBootstrapIdentity(argv):null})){fail5("managed startup transaction is missing at commit")}console.log(`[managed-startup] committed ${agent} shared state`);return}if(argv.length===5&&argv[0]==="--clear-shared-state-commit-receipt"){requireRoot();const agent=exactAgent2(readCliAgent(argv,5));const bootstrapIdentity=readCliBootstrapIdentity(argv);if(!clearManagedStartupSharedStateCommitReceipt(agent,{bootstrapIdentity})){fail5("managed startup durable commit receipt is missing at cleanup")}console.log(`[managed-startup] cleared ${agent} durable shared-state commit receipt`);return}if(argv.length===8&&argv[0]==="--shared-state-transaction-status"&&argv[7]==="--read-only-receipt"){requireRoot();const agent=exactAgent2(readCliAgent(argv,8));const profileFingerprint=readCliFingerprint(argv);const bootstrapIdentity=readCliBootstrapIdentity(argv);process.stdout.write(`${getManagedStartupSharedStateTransactionStatus({agent,profileFingerprint,bootstrapIdentity},{readOnlyReceipt:true})} `);return}const result=await applyManagedStartupImageProfile(readCliAgent(argv));console.log(result.adapterApplied?`[managed-startup] applied ${result.agent} profile ${result.fingerprint}`:`[managed-startup] ${result.agent} profile ${result.fingerprint} is already committed`)}var MANAGED_BOOTSTRAP_ENVELOPE_SCHEMA_VERSION=1;var MANAGED_BOOTSTRAP_REQUEST_FILE="/var/lib/nemoclaw-managed-bootstrap-request.json";var MANAGED_BOOTSTRAP_REQUEST_TAR_PATH=MANAGED_BOOTSTRAP_REQUEST_FILE.replace(/^\/+/,"");var MANAGED_BOOTSTRAP_COMPLETION_FILE="/run/nemoclaw/managed-bootstrap-completion.json";var MANAGED_BOOTSTRAP_ENVELOPE_MAX_BYTES=Math.ceil(MANAGED_STARTUP_ROOT_APPLY_MAX_BYTES/3)*4+1024;var MANAGED_BOOTSTRAP_COMPLETION_MAX_BYTES=1024;var BOOTSTRAP_IDENTITY_RE=/^[a-f0-9]{64}$/u;var STANDARD_BASE64_RE2=/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;function fail6(message){throw new Error(`Managed bootstrap envelope is invalid: ${message}`)}function serializeManagedBootstrapEnvelope(input){if(!BOOTSTRAP_IDENTITY_RE.test(input.bootstrapIdentity)){fail6("bootstrap identity must be 32 random bytes encoded as lowercase hex")}const request=Buffer.from(serializeManagedStartupRootApplyRequest(input.rootApplyRequest),"utf8").toString("base64");const serialized=`${JSON.stringify({bootstrapIdentity:input.bootstrapIdentity,rootApplyRequestB64:request,schemaVersion:MANAGED_BOOTSTRAP_ENVELOPE_SCHEMA_VERSION})} `;if(Buffer.byteLength(serialized,"utf8")>MANAGED_BOOTSTRAP_ENVELOPE_MAX_BYTES){fail6("serialized envelope exceeds its bounded transport")}return serialized}function parseManagedBootstrapEnvelope(text){if(text.includes("\0"))fail6("serialized envelope contains NUL");if(text.length===0||Buffer.byteLength(text,"utf8")>MANAGED_BOOTSTRAP_ENVELOPE_MAX_BYTES){fail6("serialized envelope is empty or too large")}let parsed;try{parsed=JSON.parse(text)}catch{fail6("serialized envelope is not valid JSON")}if(typeof parsed!=="object"||parsed===null||Array.isArray(parsed)){fail6("serialized envelope must be an object")}const record=parsed;if(Object.keys(record).sort().join(",")!==["bootstrapIdentity","rootApplyRequestB64","schemaVersion"].sort().join(",")||record.schemaVersion!==MANAGED_BOOTSTRAP_ENVELOPE_SCHEMA_VERSION||typeof record.bootstrapIdentity!=="string"||!BOOTSTRAP_IDENTITY_RE.test(record.bootstrapIdentity)||typeof record.rootApplyRequestB64!=="string"||!STANDARD_BASE64_RE2.test(record.rootApplyRequestB64)){fail6("serialized envelope has an invalid schema")}const requestBytes=Buffer.from(record.rootApplyRequestB64,"base64");if(requestBytes.toString("base64")!==record.rootApplyRequestB64){fail6("root application request transport is non-canonical")}const rootApplyRequest=parseManagedStartupRootApplyRequest(requestBytes.toString("utf8"));const envelope=Object.freeze({schemaVersion:MANAGED_BOOTSTRAP_ENVELOPE_SCHEMA_VERSION,bootstrapIdentity:record.bootstrapIdentity,rootApplyRequest});if(serializeManagedBootstrapEnvelope(envelope)!==text){fail6("serialized envelope is not canonical")}return envelope}function serializeManagedBootstrapImageCompletion(completion){if(!BOOTSTRAP_IDENTITY_RE.test(completion.bootstrapIdentity)||!BOOTSTRAP_IDENTITY_RE.test(completion.profileFingerprint)){fail6("image completion identity is invalid")}if(!["openclaw","hermes","langchain-deepagents-code"].includes(completion.agent)){fail6("image completion agent is invalid")}if(typeof completion.transactionPending!=="boolean"){fail6("image completion transaction state is invalid")}return`${JSON.stringify({agent:completion.agent,bootstrapIdentity:completion.bootstrapIdentity,profileFingerprint:completion.profileFingerprint,schemaVersion:MANAGED_BOOTSTRAP_ENVELOPE_SCHEMA_VERSION,transactionPending:completion.transactionPending})} diff --git a/tools/openshell-agent/runtime.mts b/tools/openshell-agent/runtime.mts index 69766d1839f..544f8ce2021 100644 --- a/tools/openshell-agent/runtime.mts +++ b/tools/openshell-agent/runtime.mts @@ -43,6 +43,23 @@ export type OpenShellUpload = { destination: string; }; +const INFERENCE_CONFIGURATION_ATTEMPTS = 6; + +function inferenceConfigurationRetryDelay( + env: NodeJS.ProcessEnv, + input: OpenShellInferenceOptions, + attempt: number, +): number { + const identity = [ + input.modelId, + env.PR_REVIEW_ADVISOR_INTEREST ?? "primary", + env.SANDBOX_NAME ?? input.gatewayId, + ].join(":"); + let hash = 0; + for (const character of identity) hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + return 2000 * 2 ** attempt + (hash % 8000); +} + export type CreateOpenShellSandboxOptions = { command: readonly string[]; driverConfig?: Readonly>; @@ -233,20 +250,25 @@ export async function configureOpenShellInference( ], { env: providerEnv }, ); - tools.run( - "openshell", - [ - "inference", - "set", - "--provider", - input.providerName, - "--model", - input.modelId, - "--timeout", - "900", - ], - { env: commandEnv }, - ); + const inferenceArgs = [ + "inference", + "set", + "--provider", + input.providerName, + "--model", + input.modelId, + "--timeout", + "900", + ] as const; + for (let attempt = 0; attempt < INFERENCE_CONFIGURATION_ATTEMPTS; attempt += 1) { + try { + tools.run("openshell", inferenceArgs, { env: commandEnv }); + return; + } catch (error) { + if (attempt === INFERENCE_CONFIGURATION_ATTEMPTS - 1) throw error; + await tools.wait(inferenceConfigurationRetryDelay(env, input, attempt)); + } + } } export function createOpenShellSandbox( diff --git a/vitest.config.ts b/vitest.config.ts index 640d347069b..ae5a8a3b530 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -216,6 +216,11 @@ export default defineConfig({ ...vitestStateIsolation, name: "installer-integration", alias: canonicalSourceAliases, + // Installer fixtures spawn nested shell, Node, Python, and SSH + // processes. Use the same bounded scheduling as the other process + // fixtures so CI cannot turn a transient spawn failure into a + // fail-closed single-host result. + ...integrationProjectScheduling, env: controlledNonLiveEnv, setupFiles: [fixtureUmaskSetup, isolatedTestStateSetup], include: [