diff --git a/.gitattributes b/.gitattributes index 5e045f821..802bc1357 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,6 +19,7 @@ *.yml text eol=lf *.md text eol=lf *.tsv text eol=lf +*.js text eol=lf # Binary files *.mp3 binary diff --git a/.github/workflows/opencode-native-e2e.yml b/.github/workflows/opencode-native-e2e.yml index 1aae0509a..67117ff5a 100644 --- a/.github/workflows/opencode-native-e2e.yml +++ b/.github/workflows/opencode-native-e2e.yml @@ -3,19 +3,33 @@ on: workflow_dispatch: inputs: plan: - description: Eight portable business cells or original eleven-cell matrix + description: Original qualification plans or seven immutable release recovery cells type: choice - options: [all-eleven, portable-eight] + options: [all-eleven, portable-eight, release-recovery] default: all-eleven required: true evidence_url: description: 'HTTPS parent evidence tar sealed after exact clean source checkout/build; no SDK copies or grants' type: string - required: true + required: false evidence_sha256: description: 'Reviewed SHA256 of that immutable external evidence tar' type: string required: true + recovery_run_id: + description: 'Original completed v1.48.1 release workflow run with successful builds and canaries' + type: string + required: false + candidate_sha: + description: 'Exact immutable release source SHA, required only for release-recovery' + type: string + required: false + recovery_cells: + description: 'Fresh primitive and business evidence scope, only for release-recovery' + type: choice + options: [first-linux-amd64-v2, windows-two, all-seven] + default: first-linux-amd64-v2 + required: true # Only a newer run for the same PR supersedes its checks. concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} @@ -25,6 +39,7 @@ permissions: contents: read jobs: native: + if: inputs.plan != 'release-recovery' name: '${{ matrix.target.platform }}/${{ matrix.target.arch }} ${{ matrix.version }}' runs-on: ${{ matrix.target.runner }} timeout-minutes: 20 @@ -97,3 +112,199 @@ jobs: path: .task-tools/artifacts/native-report.json if-no-files-found: error retention-days: 14 + recovery-inputs: + if: inputs.plan == 'release-recovery' + runs-on: ubuntu-22.04 + permissions: + contents: read + actions: read + outputs: + run_id: ${{ steps.origin.outputs.run_id }} + candidate_sha: ${{ steps.origin.outputs.candidate_sha }} + evidence_sha256: ${{ steps.origin.outputs.evidence_sha256 }} + matrix: ${{ steps.origin.outputs.matrix }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Verify original release source, builds, canaries and artifact identities + id: origin + env: + GH_TOKEN: ${{ github.token }} + AN_RECOVERY_RUN_ID: ${{ inputs.recovery_run_id }} + AN_RECOVERY_CANDIDATE_SHA: ${{ inputs.candidate_sha }} + AN_EVIDENCE_SHA256: ${{ inputs.evidence_sha256 }} + AN_RECOVERY_CELLS: ${{ inputs.recovery_cells }} + run: python -B scripts/release-opencode-recovery.py --verify-origin + release-recovery: + if: inputs.plan == 'release-recovery' + needs: recovery-inputs + name: 'Recover release ${{ matrix.target.platform }}/${{ matrix.target.arch }} ${{ matrix.version }}' + runs-on: ${{ matrix.target.runner }} + timeout-minutes: 20 + permissions: + contents: read + actions: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.recovery-inputs.outputs.matrix) }} + defaults: + run: + working-directory: source + shell: bash + env: + AN_RECOVERY_RUN_ID: ${{ needs.recovery-inputs.outputs.run_id }} + AN_RECOVERY_CANDIDATE_SHA: ${{ needs.recovery-inputs.outputs.candidate_sha }} + SOURCE_COMMIT: ${{ needs.recovery-inputs.outputs.candidate_sha }} + AN_EVIDENCE_SHA256: ${{ needs.recovery-inputs.outputs.evidence_sha256 }} + AN_EVIDENCE_ARCHIVE: .task-tools/artifacts/custody/release-opencode-inputs.tar.gz + AN_OS: ${{ matrix.target.platform }} + AN_ARCH: ${{ matrix.target.arch }} + AN_VERSION: ${{ matrix.version }} + steps: + - name: Preserve canonical LF Git bytes before both Windows checkouts + if: runner.os == 'Windows' + working-directory: ${{ github.workspace }} + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - uses: actions/checkout@v7 + with: + ref: ${{ needs.recovery-inputs.outputs.candidate_sha }} + path: source + fetch-depth: 0 + persist-credentials: false + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + path: recovery-tools + persist-credentials: false + - uses: actions/setup-go@v7 + with: {go-version: '1.27.1', cache: false} + - uses: actions/setup-node@v6 + with: {node-version: '24.21.0'} + - uses: actions/setup-python@v7 + with: {python-version: '3.12'} + - name: Initialize unqualified recovery report + run: | + mkdir -p .task-tools/artifacts + python -c 'import json,pathlib; pathlib.Path(".task-tools/artifacts/native-report.json").write_text(json.dumps({"status":"unqualified","businessPhasesStarted":False,"firstFailedPrerequisite":"recovery_inputs_not_verified"})+"\n")' + - uses: actions/download-artifact@v8 + with: + github-token: ${{ github.token }} + run-id: ${{ needs.recovery-inputs.outputs.run_id }} + name: binaries-${{ matrix.target.platform }}-${{ matrix.target.arch }} + path: source/dist + - uses: actions/download-artifact@v8 + with: + github-token: ${{ github.token }} + run-id: ${{ needs.recovery-inputs.outputs.run_id }} + name: release-opencode-inputs-${{ needs.recovery-inputs.outputs.candidate_sha }} + path: source/.task-tools/artifacts/custody + - name: Restore executable mode without changing release binary bytes + if: runner.os != 'Windows' + env: + MAIN_BINARY: ${{ matrix.target.binary }} + run: chmod +x "dist/$MAIN_BINARY" + - name: Linux private bus prerequisites + if: matrix.target.platform == 'linux' + run: sudo apt-get update && sudo apt-get install -y libasound2-dev dbus iproute2 + - name: Verify and stage original sealed parent archive + id: parent + run: python -B scripts/testdata/opencode-native-e2e/ci_inputs.py --stage-evidence + - name: Verify unchanged source and Go identity, and build original private receiver + env: + AN_MANIFEST: ${{ steps.parent.outputs.manifest }} + AN_MANIFEST_SHA256: ${{ steps.parent.outputs.sha256 }} + run: | + python -B ../recovery-tools/scripts/release-opencode-recovery.py --verify-inputs + python -B scripts/testdata/opencode-native-e2e/ci_inputs.py + - name: Prefetch pinned tooling and Go dependencies before native isolation + run: | + set -euo pipefail + npm ci --prefix opencode-plugin + go mod download + - name: Compile exact-source reader witness offline without any native host + id: primitives + env: + AN_MANIFEST: ${{ steps.parent.outputs.manifest }} + AN_MANIFEST_SHA256: ${{ steps.parent.outputs.sha256 }} + run: python -B ../recovery-tools/scripts/release-opencode-recovery.py --prepare-primitives + - name: Observe fresh primitives and business scenarios with no qualification grants + env: + AN_MANIFEST: ${{ steps.parent.outputs.manifest }} + AN_MANIFEST_SHA256: ${{ steps.parent.outputs.sha256 }} + AN_CLOCK_TEMP_BASE: ${{ steps.primitives.outputs.clock_temp_base }} + AN_GO_MODULE_CACHE: ${{ steps.primitives.outputs.go_module_cache }} + run: | + set -euo pipefail + if [ "$AN_OS" = linux ]; then + export AN_HOST_NETNS="$(readlink /proc/self/ns/net)" + sudo --preserve-env=PATH,AN_MANIFEST,AN_MANIFEST_SHA256,AN_OS,AN_ARCH,AN_VERSION,AN_RECOVERY_RUN_ID,AN_RECOVERY_CANDIDATE_SHA,AN_EVIDENCE_SHA256,AN_HOST_NETNS,AN_CLOCK_TEMP_BASE,AN_GO_MODULE_CACHE,SOURCE_COMMIT,GITHUB_ACTIONS unshare --net -- bash -c ' + set -euo pipefail + ip link set lo up + exec setpriv --reuid "$1" --regid "$2" --init-groups -- env HOME="$3" USER="$4" LOGNAME="$4" python -B ../recovery-tools/scripts/release-opencode-recovery.py --execute + ' bash "$(id -u)" "$(id -g)" "$HOME" "$(id -un)" + else + python -B ../recovery-tools/scripts/release-opencode-recovery.py --execute + fi + - uses: actions/upload-artifact@v7 + if: always() + with: + name: opencode-release-recovery-${{ matrix.target.platform }}-${{ matrix.target.arch }}-${{ matrix.version }} + path: | + source/.task-tools/artifacts/native-report.json + source/.task-tools/artifacts/native/ + source/.task-tools/artifacts/primitives/ + include-hidden-files: true + if-no-files-found: error + retention-days: 14 + - uses: actions/upload-artifact@v7 + if: always() && steps.primitives.outputs.clock_temp_base != '' + with: + name: opencode-release-clock-raw-${{ matrix.target.platform }}-${{ matrix.target.arch }}-${{ matrix.version }} + path: ${{ steps.primitives.outputs.clock_temp_base }}/ + include-hidden-files: true + if-no-files-found: error + retention-days: 14 + - uses: actions/upload-artifact@v7 + if: always() + with: + name: opencode-release-recovery-summary-${{ matrix.target.platform }}-${{ matrix.target.arch }}-${{ matrix.version }} + include-hidden-files: true + path: | + source/.task-tools/artifacts/native-report.json + source/.task-tools/artifacts/primitives/clock-report.json + source/.task-tools/artifacts/primitives/reader-report.json + source/.task-tools/artifacts/parent-inputs/manifest.json + source/.task-tools/artifacts/parent-inputs/release-inputs-receipt.json + source/.task-tools/artifacts/primitives/sealed/ + source/.task-tools/artifacts/windows-acl-preparation.json + source/.task-tools/artifacts/primitives/TEST-reader-native/reader-private.jsonl + source/.task-tools/artifacts/primitives/TEST-reader-native/result.json + source/.task-tools/artifacts/primitives/TEST-reader-build/build-receipt.json + source/.task-tools/artifacts/primitives/TEST-reader-build/build-metafile.json + source/.task-tools/artifacts/native/**/native-private.jsonl + source/.task-tools/artifacts/native/**/provider-redacted.jsonl + source/.task-tools/artifacts/native/**/desktop-private.jsonl + source/.task-tools/artifacts/native/**/host-private.log + source/.task-tools/artifacts/native/**/updated-host-private.log + source/.task-tools/artifacts/native/**/reinstalled-host-private.log + if-no-files-found: error + retention-days: 14 + - uses: actions/upload-artifact@v7 + if: always() && steps.primitives.outputs.clock_temp_base != '' + with: + name: opencode-release-clock-summary-${{ matrix.target.platform }}-${{ matrix.target.arch }}-${{ matrix.version }} + include-hidden-files: true + path: | + ${{ steps.primitives.outputs.clock_temp_base }}/**/metadata.json + ${{ steps.primitives.outputs.clock_temp_base }}/**/samples.private.json + ${{ steps.primitives.outputs.clock_temp_base }}/**/module-stream-*.private + ${{ steps.primitives.outputs.clock_temp_base }}/**/helper-*.private + ${{ steps.primitives.outputs.clock_temp_base }}/**/os-loader-files.private.json + ${{ steps.primitives.outputs.clock_temp_base }}/**/loader.private.json + ${{ steps.primitives.outputs.clock_temp_base }}/**/helper-lifecycle.private.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/post-publish-windows-e2e.yml b/.github/workflows/post-publish-windows-e2e.yml index 734c7185b..0e8e3922a 100644 --- a/.github/workflows/post-publish-windows-e2e.yml +++ b/.github/workflows/post-publish-windows-e2e.yml @@ -1,113 +1,75 @@ name: Post-publish Windows E2E - -# Manual, on-demand smoke test that exercises the PUBLIC release artifacts -# (not a local build) on a real Windows runner. Used to validate a specific -# published tag from public URLs only, in an isolated profile - this never -# touches a real user's machine, HOME, or config. - on: workflow_dispatch: inputs: tag: - description: 'Release tag to validate (e.g. v1.42.0)' + description: Exact partial release tag required: true - + default: v1.48.0 + source_sha: + description: Exact product source SHA + required: true + phase: + description: Asset visibility + type: choice + options: [draft, public] + default: draft + platform: + description: Qualify all platforms or only a failed Windows phase + type: choice + options: [all, windows-amd64] + default: all + expected_stable: + description: Latest stable must remain this tag + default: v1.46.1 + required: true +permissions: + # GitHub hides draft releases from read-only tokens; no mutation calls are used. + contents: write jobs: - windows-e2e: - name: Validate public release on Windows - runs-on: windows-latest + package-e2e: + name: Prerelease ${{ matrix.os }} ${{ matrix.arch }} + runs-on: ${{ matrix.runner }} timeout-minutes: 20 - defaults: - run: - shell: bash - env: - TAG: ${{ inputs.tag }} - RELEASE_URL: https://github.com/777genius/agent-notifications/releases/download/${{ inputs.tag }} - CHECKSUMS_URL: https://github.com/777genius/agent-notifications/releases/download/${{ inputs.tag }}/checksums.txt - MODERN_NOTIFIER_URL: https://github.com/777genius/agent-notifications/releases/download/${{ inputs.tag }}/ClaudeNotifier.app.zip - + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.platform == 'windows-amd64' && '[{"runner":"windows-latest","os":"windows","arch":"amd64"}]' || '[{"runner":"ubuntu-latest","os":"linux","arch":"amd64"},{"runner":"ubuntu-24.04-arm","os":"linux","arch":"arm64"},{"runner":"windows-latest","os":"windows","arch":"amd64"}]') }} steps: - - name: Prepare isolated profile - shell: pwsh - run: | - $root = Join-Path $env:RUNNER_TEMP 'e2e-home' - New-Item -ItemType Directory -Force -Path $root | Out-Null - foreach ($name in @('.claude', '.codex', 'tmp', 'APPDATA', 'LOCALAPPDATA', 'bundle')) { - New-Item -ItemType Directory -Force -Path (Join-Path $root $name) | Out-Null - } - # The runner temp/workspace ACLs may grant access to other users. - # The installer intentionally rejects those paths for managed state. - $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value - & icacls $root /inheritance:r /grant:r "*${userSid}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /T - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - @( - "E2E_HOME=$root" - "HOME=$root" - "USERPROFILE=$root" - "APPDATA=$(Join-Path $root 'APPDATA')" - "LOCALAPPDATA=$(Join-Path $root 'LOCALAPPDATA')" - "CODEX_HOME=$(Join-Path $root '.codex')" - "CLAUDE_CONFIG_DIR=$(Join-Path $root '.claude')" - "TMPDIR=$(Join-Path $root 'tmp')" - "TMP=$(Join-Path $root 'tmp')" - "TEMP=$(Join-Path $root 'tmp')" - "INSTALL_TARGET_DIR=$(Join-Path $root 'bundle')" - ) | Add-Content -Path $env:GITHUB_ENV - - - name: Download install.sh from main - run: | - set -euo pipefail - curl -fsSL https://raw.githubusercontent.com/777genius/agent-notifications/main/bin/install.sh -o "$E2E_HOME/install.sh" - - - name: Fresh install of public release (install.sh, isolated target dir) - run: | - set -euo pipefail - bash "$E2E_HOME/install.sh" - echo "--- installed files ---" - ls -la "$INSTALL_TARGET_DIR/bin" || ls -la "$INSTALL_TARGET_DIR" - - - name: Checksum verification against public checksums.txt - run: | - set -euo pipefail - curl -fsSL "$CHECKSUMS_URL" -o "$RUNNER_TEMP/checksums.txt" - EXE=$(find "$INSTALL_TARGET_DIR" -iname "claude-notifications-windows-amd64.exe" | head -1) - echo "exe=$EXE" - EXPECTED=$(grep "claude-notifications-windows-amd64.exe" "$RUNNER_TEMP/checksums.txt" | awk '{print $1}') - # sha256sum prefixes the whole output line with a backslash when the - # file path itself contains one (GNU coreutils escaping convention); - # our path mixes \ and / on Windows, so strip that marker, not the hash. - ACTUAL=$(sha256sum "$EXE" | awk '{print $1}') - ACTUAL="${ACTUAL#\\}" - echo "expected=$EXPECTED actual=$ACTUAL" - [ "$EXPECTED" = "$ACTUAL" ] || { echo "CHECKSUM MISMATCH"; exit 1; } - - - name: Version + config smoke test - run: | - set -euo pipefail - EXE=$(find "$INSTALL_TARGET_DIR" -iname "claude-notifications-windows-amd64.exe" | head -1) - "$EXE" version - env HOME="$E2E_HOME" CLAUDE_CONFIG_DIR="$E2E_HOME/.claude" "$EXE" config path - env HOME="$E2E_HOME" CLAUDE_CONFIG_DIR="$E2E_HOME/.claude" "$EXE" config inspect || true - - - name: Repeat install (update path, idempotency + historical-baseline check) - run: | - set -euo pipefail - bash "$E2E_HOME/install.sh" 2>&1 | tee "$RUNNER_TEMP/repeat-install.log" - echo "exit_code=${PIPESTATUS[0]}" - if grep -qi "traceback\|assertionerror" "$RUNNER_TEMP/repeat-install.log"; then - echo "FOUND_PYTHON_TRACEBACK=true" >> "$GITHUB_ENV" - else - echo "FOUND_PYTHON_TRACEBACK=false" >> "$GITHUB_ENV" - fi - - - name: windows-hooks exec-form output - run: | - set -euo pipefail - EXE=$(find "$INSTALL_TARGET_DIR" -iname "claude-notifications-windows-amd64.exe" | head -1) - "$EXE" windows-hooks --exe "$EXE" - - - name: Summary - run: | - set -euo pipefail - echo "Tag validated: $TAG" - echo "Python traceback seen on repeat install: ${FOUND_PYTHON_TRACEBACK:-unknown}" + - name: Validate exact source input + shell: bash + env: + SOURCE_SHA: ${{ inputs.source_sha }} + run: '[[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]' + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.source_sha }} + path: SOURCE + persist-credentials: false + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + path: SCRIPT + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - name: Download and qualify exact release assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TEST_TAG: ${{ inputs.tag }} + TEST_PHASE: ${{ inputs.phase }} + TEST_STABLE: ${{ inputs.expected_stable }} + TEST_SOURCE_SHA: ${{ inputs.source_sha }} + TEST_OS: ${{ matrix.os }} + TEST_ARCH: ${{ matrix.arch }} + TEST_BASH: ${{ matrix.os == 'windows' && 'C:/Program Files/Git/bin/bash.exe' || 'bash' }} + run: python SCRIPT/scripts/prerelease-package-e2e.py --source SOURCE --report evidence/package-e2e.json + - uses: actions/upload-artifact@v7 + if: always() + with: + name: prerelease-package-${{ inputs.phase }}-${{ matrix.os }}-${{ matrix.arch }} + path: evidence/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7f0ed0d2..6068068b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,18 +61,6 @@ jobs: strategy: matrix: include: - # macOS builds - use native runners (CGO required for malgo) - - os: macos-15-intel - platform: darwin - arch: amd64 - binary: claude-notifications-darwin-amd64 - macos_deployment_target: '12.0' - - os: macos-15 - platform: darwin - arch: arm64 - binary: claude-notifications-darwin-arm64 - macos_deployment_target: '12.0' - # Linux builds - use native runners - os: ubuntu-22.04 platform: linux @@ -90,6 +78,12 @@ jobs: binary: claude-notifications-windows-amd64.exe steps: + - name: Preserve canonical source bytes on Windows + if: runner.os == 'Windows' + shell: bash + run: | + git config --global core.autocrlf false + git config --global core.eol lf - name: Checkout code uses: actions/checkout@v7 with: @@ -110,7 +104,6 @@ jobs: - name: Build binaries env: CGO_ENABLED: '1' - MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macos_deployment_target }} shell: bash run: | mkdir -p dist @@ -135,20 +128,6 @@ jobs: go build -ldflags="-s -w -H=windowsgui" -trimpath -o "dist/$FOCUS_BINARY" ./cmd/claude-notifications fi - - name: Verify macOS deployment target - if: matrix.platform == 'darwin' - run: | - for binary in dist/*; do - minos="$(otool -l "$binary" | awk '$1 == "minos" { print $2; exit }')" - case "$minos" in - 12|12.*) ;; - *) - echo "$binary has unexpected minimum macOS version: ${minos:-missing}" - exit 1 - ;; - esac - done - - name: Build version-bound portable package env: RELEASE_TAG: ${{ github.ref_name }} @@ -169,81 +148,11 @@ jobs: with: name: binaries-${{ matrix.platform }}-${{ matrix.arch }} path: dist/* - retention-days: 1 - - build-notifier: - name: Build ClaudeNotifier.app (macOS) - needs: verify-version - runs-on: macos-15 - timeout-minutes: 60 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Import Apple certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - CERTIFICATE_PATH="$RUNNER_TEMP/certificate.p12" - KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" - KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" - - if ! echo -n "$APPLE_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH" 2>/dev/null; then - echo -n "$APPLE_CERTIFICATE" | base64 -D > "$CERTIFICATE_PATH" - fi - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \ - -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - security set-key-partition-list -S apple-tool:,apple: \ - -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - EXISTING_KEYCHAINS=() - while IFS= read -r keychain; do - keychain="${keychain//\"/}" - if [ -n "$keychain" ]; then - EXISTING_KEYCHAINS+=("$keychain") - fi - done < <(security list-keychains -d user) - - security list-keychains -d user -s "$KEYCHAIN_PATH" "${EXISTING_KEYCHAINS[@]}" - - - name: Build, sign, and notarize ClaudeNotifier.app - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - cd swift-notifier - bash scripts/build-app.sh --ci - - - name: Create zip artifact - run: | - cd swift-notifier - if [ ! -f ClaudeNotifier.app.managed-runtime.json ]; then - echo "missing managed runtime attestation sidecar" >&2 - exit 1 - fi - ditto -c -k --keepParent ClaudeNotifier.app ClaudeNotifier.app.zip - zip -u ClaudeNotifier.app.zip ClaudeNotifier.app.managed-runtime.json - unzip -l ClaudeNotifier.app.zip | grep -F ClaudeNotifier.app.managed-runtime.json - ls -lh ClaudeNotifier.app.zip - - - name: Upload notifier artifact - uses: actions/upload-artifact@v7 - with: - name: ClaudeNotifier - path: swift-notifier/ClaudeNotifier.app.zip - retention-days: 1 + retention-days: 14 create-release: name: Prepare draft GitHub Release - needs: [build-matrix, build-notifier, test-binaries, qualify-opencode] + needs: [build-matrix, test-binaries, qualify-opencode] runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -259,12 +168,6 @@ jobs: pattern: binaries-* path: dist-artifacts - - name: Download notifier artifact - uses: actions/download-artifact@v8 - with: - name: ClaudeNotifier - path: dist-artifacts/ClaudeNotifier - - name: Organize binaries run: | mkdir -p dist @@ -285,6 +188,7 @@ jobs: files: dist/* draft: true prerelease: false + make_latest: false generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -307,20 +211,18 @@ jobs: platform: linux arch: arm64 binary: claude-notifications-linux-arm64 - - os: macos-15-intel - platform: darwin - arch: amd64 - binary: claude-notifications-darwin-amd64 - - os: macos-26 - platform: darwin - arch: arm64 - binary: claude-notifications-darwin-arm64 - os: windows-latest platform: windows arch: amd64 binary: claude-notifications-windows-amd64.exe steps: + - name: Preserve canonical source bytes on Windows + if: runner.os == 'Windows' + shell: bash + run: | + git config --global core.autocrlf false + git config --global core.eol lf - name: Checkout artifact qualification tests uses: actions/checkout@v7 with: @@ -400,9 +302,53 @@ jobs: shell: bash + prepare-opencode-inputs: + name: Seal same-run OpenCode release inputs + needs: build-matrix + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + sha256: ${{ steps.seal.outputs.sha256 }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-go@v7 + with: {go-version: '1.27.1', cache: false} + - uses: actions/setup-node@v6 + with: {node-version: '24.21.0', package-manager-cache: false} + - uses: actions/setup-python@v7 + with: {python-version: '3.12'} + - uses: actions/download-artifact@v8 + with: + pattern: binaries-* + merge-multiple: true + path: dist + - name: Rebuild and verify the exact self-contained observer + working-directory: opencode-plugin + shell: bash + run: | + set -euo pipefail + mkdir -p ../.task-tools/artifacts + cp ../internal/opencodeplugin/dist/agent-notifications.js ../.task-tools/artifacts/original-observer.js + npm ci --ignore-scripts + npm run check:candidate + npm run build + cmp ../.task-tools/artifacts/original-observer.js ../internal/opencodeplugin/dist/agent-notifications.js + cp ../internal/opencodeplugin/dist/agent-notifications.js ../.task-tools/artifacts/rebuilt-observer.js + - name: Seal actual reviewed source bytes and exact release binary custody + id: seal + run: python -B scripts/release-opencode-inputs.py --scope linux-windows --rebuilt .task-tools/artifacts/rebuilt-observer.js + - uses: actions/upload-artifact@v7 + with: + name: release-opencode-inputs-${{ github.sha }} + path: .task-tools/artifacts/release-opencode-inputs.tar.gz + if-no-files-found: error + retention-days: 14 + qualify-opencode: name: Qualify release OpenCode ${{ matrix.target.platform }}/${{ matrix.target.arch }} ${{ matrix.version }} - needs: [build-matrix, build-notifier] + needs: [build-matrix, prepare-opencode-inputs] runs-on: ${{ matrix.target.runner }} timeout-minutes: 20 strategy: @@ -412,28 +358,31 @@ jobs: target: - {runner: ubuntu-22.04, platform: linux, arch: amd64, binary: claude-notifications-linux-amd64} - {runner: ubuntu-22.04-arm, platform: linux, arch: arm64, binary: claude-notifications-linux-arm64} - - {runner: macos-15, platform: darwin, arch: arm64, binary: claude-notifications-darwin-arm64} - - {runner: macos-15-intel, platform: darwin, arch: amd64, binary: claude-notifications-darwin-amd64} - {runner: windows-latest, platform: windows, arch: amd64, binary: claude-notifications-windows-amd64.exe} include: - version: '1.18.34' target: {runner: ubuntu-22.04, platform: linux, arch: amd64, binary: claude-notifications-linux-amd64} env: - # These are reviewed immutable inputs for this exact tag/build, never grants. - AN_EVIDENCE_URL: ${{ vars.OPENCODE_RELEASE_EVIDENCE_URL }} - AN_EVIDENCE_SHA256: ${{ vars.OPENCODE_RELEASE_EVIDENCE_SHA256 }} + AN_EVIDENCE_ARCHIVE: .task-tools/artifacts/custody/release-opencode-inputs.tar.gz + AN_EVIDENCE_SHA256: ${{ needs.prepare-opencode-inputs.outputs.sha256 }} AN_OS: ${{ matrix.target.platform }} AN_ARCH: ${{ matrix.target.arch }} AN_VERSION: ${{ matrix.version }} MAIN_BINARY: ${{ matrix.target.binary }} steps: + - name: Preserve canonical source bytes on Windows + if: runner.os == 'Windows' + shell: bash + run: | + git config --global core.autocrlf false + git config --global core.eol lf - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-go@v7 with: {go-version: '1.27.1', cache: false} - uses: actions/setup-node@v6 - with: {node-version: '24.21.0'} + with: {node-version: '24.21.0', package-manager-cache: false} - uses: actions/setup-python@v7 with: {python-version: '3.12'} - name: Initialize unqualified report @@ -445,16 +394,11 @@ jobs: with: name: binaries-${{ matrix.target.platform }}-${{ matrix.target.arch }} path: dist - - name: Download actual signed release helper - if: matrix.target.platform == 'darwin' + - name: Download same-run sealed release custody uses: actions/download-artifact@v8 with: - name: ClaudeNotifier - path: .task-tools/artifacts/release-native-app - - name: Extract actual signed release helper - if: matrix.target.platform == 'darwin' - shell: bash - run: ditto -x -k .task-tools/artifacts/release-native-app/ClaudeNotifier.app.zip .task-tools/artifacts/release-native-app + name: release-opencode-inputs-${{ github.sha }} + path: .task-tools/artifacts/custody - name: Make normal release binary executable if: runner.os != 'Windows' shell: bash @@ -462,7 +406,7 @@ jobs: - name: Linux private bus prerequisites if: matrix.target.platform == 'linux' run: sudo apt-get update && sudo apt-get install -y libasound2-dev dbus iproute2 - - name: Stage immutable external parent artifacts + - name: Verify and stage same-run parent archive id: parent shell: bash run: python -B scripts/testdata/opencode-native-e2e/ci_inputs.py --stage-evidence @@ -485,12 +429,6 @@ jobs: os.environ['AN_ARCH'], os.environ['AN_VERSION'], os.environ['AN_MANIFEST_SHA256']) expected = (inputs.repo / 'dist' / os.environ['MAIN_BINARY']).resolve(strict=True) r.require(binary == expected, 'normal_release_binary_required') - if cell['os'] == 'darwin': - app = r.native_app(cell['nativeApp']) - expected_app = inputs.artifacts / 'release-native-app/ClaudeNotifier.app' - r.require(app == expected_app.resolve(strict=True), 'signed_release_native_app_required') - sidecar = r.checked_file(inputs.repo, cell['nativeApp']['sidecar']) - r.require(sidecar == expected_app.with_name(expected_app.name + '.managed-runtime.json').resolve(strict=True), 'release_native_app_sidecar_required') except Exception as error: r.write_json(inputs.artifacts / 'native-report.json', { 'status': 'unqualified', 'businessPhasesStarted': False, @@ -513,7 +451,14 @@ jobs: export AN_SUITE=smoke if [ "$AN_OS/$AN_ARCH" = linux/amd64 ]; then export AN_SUITE=full; fi if [ "$AN_OS" = linux ]; then - sudo --preserve-env=PATH,AN_MANIFEST,AN_MANIFEST_SHA256,AN_OS,AN_ARCH,AN_VERSION,AN_SUITE unshare --net -- bash -c 'ip link set lo up; python -B scripts/testdata/opencode-native-e2e/ci_inputs.py --execute' + # Create the private network as root, then keep checkout/artifact + # ownership and Git source verification under the runner identity. + export AN_HOST_NETNS="$(readlink /proc/self/ns/net)" + sudo --preserve-env=PATH,AN_MANIFEST,AN_MANIFEST_SHA256,AN_OS,AN_ARCH,AN_VERSION,AN_SUITE,AN_HOST_NETNS unshare --net -- bash -c ' + set -euo pipefail + ip link set lo up + exec setpriv --reuid "$1" --regid "$2" --init-groups -- env HOME="$3" USER="$4" LOGNAME="$4" python -B scripts/testdata/opencode-native-e2e/ci_inputs.py --execute + ' bash "$(id -u)" "$(id -g)" "$HOME" "$(id -un)" else python -B scripts/testdata/opencode-native-e2e/ci_inputs.py --execute fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 5691d92a9..6e718a595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.48.1] - 2026-10-06 + ### Added -- One OpenCode plugin supports V1 `server` and V2 `setup`, with native qualification targeting 1.18.33, 1.18.34 and 2.0.21; final candidate qualification remains pending. V2 notifications verify native ownership and settled execution, suppress child sessions, and recover after temporary ownership failures ([#294](https://github.com/777genius/agent-notifications/pull/294)). SDK 0.3.0 publication and registry qualification remain pending. +- One OpenCode plugin supports V1 `server` and V2 `setup`, with compatibility targeting 1.18.33, 1.18.34 and 2.0.21. V2 notifications verify native ownership and settled execution, suppress child sessions, and recover after temporary ownership failures ([#294](https://github.com/777genius/agent-notifications/pull/294)). The self-contained bundle incorporates SDK 0.3.0 built from the reviewed vendored tarball; separate SDK publication remains pending. - Interactive agent selection in the public installer, with separate installation scope and notification consent for each selected product. Cancellation performs no installation; partial failures retain completed installations and show recovery guidance ([#296](https://github.com/777genius/agent-notifications/pull/296)). - The `licenses` command and third-party license notices in portable packages and release assets. +- Explicit Cursor setup selection, per-channel consent and managed installed Stop-event delivery, with selected-profile checks, repeat setup, revocation and recovery contracts ([#342](https://github.com/777genius/agent-notifications/pull/342), [#344](https://github.com/777genius/agent-notifications/pull/344), [#356](https://github.com/777genius/agent-notifications/pull/356), [#364](https://github.com/777genius/agent-notifications/pull/364)). Desktop delivery and exact-chat navigation require separate qualification. ### Changed - OpenCode V1 now consumes the 0.3.0 observer instead of 0.1.0: failed assistant steps produce terminal errors at idle, context overflow waits for recovery, and observed user association filters replayed completions. The V1 entry point remains supported. +- OpenCode notifications include session titles and the current question or permission context ([#326](https://github.com/777genius/agent-notifications/pull/326)). - Claude CLI hooks now use the SDK-backed event source while preserving command invocation and notification handling ([#264](https://github.com/777genius/agent-notifications/pull/264), [#266](https://github.com/777genius/agent-notifications/pull/266)). -- Fresh macOS Codex and combined bootstrap installs enable chat navigation by default when the registered official Codex Desktop app passes signature verification. Existing navigation policy and explicit choices remain preserved. If the verified app is unavailable, bootstrap reports its fallback to informational notifications without navigation. +- The source includes fresh macOS Codex and combined bootstrap installs that enable chat navigation by default when the registered official Codex Desktop app passes signature verification. Existing navigation policy and explicit choices remain preserved. If the verified app is unavailable, bootstrap reports its fallback to informational notifications without navigation. ### Fixed +- Preserve valid four-second notification budgets when repeated Windows clock readings cross a floating-point boundary. - Managed macOS installations recover after APFS device numbers change across a reboot, while preserving inode, physical parent, content and volume ownership checks. Install, update, reselection, purge and interrupted recovery accept valid persisted identities without adopting replacement assets ([#301](https://github.com/777genius/agent-notifications/issues/301)). - Failed lazy hook installations wait 300 seconds before retrying the same product, release and runtime root, prevent concurrent duplicate attempts, and continue dispatching an available previous binary. Manual repair and a different release bypass the matching cooldown ([#302](https://github.com/777genius/agent-notifications/issues/302)). - macOS LaunchServices registration uses committed durable native generations instead of temporary downloaded apps. Failed and disposable acquisitions do not register; successful commits reconcile obsolete conventional registrations owned by this product while retaining active, previous and published native generations. Foreign registrations remain untouched ([#302](https://github.com/777genius/agent-notifications/issues/302)). @@ -30,6 +35,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Installation preserves existing merged Claude hooks instead of applying legacy cleanup to user settings ([#265](https://github.com/777genius/agent-notifications/pull/265)). - Unchanged managed legacy skills migrate to the canonical `agent-notifications` skill without replacing user edits ([#306](https://github.com/777genius/agent-notifications/pull/306)). +### Platform notes +- v1.48.1 publishes only Linux amd64/arm64 and Windows amd64 artifacts. macOS source changes listed above are not shipped to the macOS channel, which remains at v1.46.1; GitHub global Latest also remains v1.46.1. +- Navigation callback lifetime, resource ownership, Linux portal restart and Wayland token-forwarding contracts have new source-bound checks ([#345](https://github.com/777genius/agent-notifications/pull/345), [#349](https://github.com/777genius/agent-notifications/pull/349), [#354](https://github.com/777genius/agent-notifications/pull/354), [#355](https://github.com/777genius/agent-notifications/pull/355), [#357](https://github.com/777genius/agent-notifications/pull/357)). Selected installed-client focus/chat navigation and Windows interactive toast/cold callback qualification remain incomplete. Artifact and protocol checks do not establish visible desktop delivery. +- Persistent OpenCode TUI/serve hosts are supported; one-shot `opencode run` delivery remains best effort during host shutdown. +- Stock Windows OpenCode V1 1.18.33 retains the accepted delayed-event limitation: a delayed completion can notify once and recur after the 24-hour claim lifetime. +- Gemini availability remains qualified on Linux amd64/arm64 and Windows amd64. + ## [1.46.1] - 2026-10-01 ### Changed diff --git a/README.md b/README.md index 44db1bf90..50522c0ed 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/777genius/agent-notifications.svg)](https://pkg.go.dev/github.com/777genius/agent-notifications) [![codecov](https://codecov.io/gh/777genius/agent-notifications/graph/badge.svg?branch=main)](https://codecov.io/gh/777genius/agent-notifications) -Desktop notifications for **Claude, Codex CLI and OpenCode**, plus **Gemini CLI on Linux/Windows (v1.47.1)**. Know when a task finishes, an agent needs input, or a tool needs approval. Claude and Codex also support sounds and click-to-focus. +Desktop notifications for **Claude, Codex CLI and OpenCode**, plus **Gemini CLI on Linux/Windows**. Know when a task finishes, an agent needs input, or a tool needs approval. Claude and Codex also support sounds and click-to-focus.

macOS, Windows, Linux (left to right) @@ -51,11 +51,11 @@ The installer selects a verified complete release for your platform: | Platform | Version | Agents | | --- | --- | --- | -| Linux amd64 / arm64 | 1.47.1 | Claude, Codex, OpenCode, Gemini | -| Windows amd64 | 1.47.1 | Claude, Codex, OpenCode, Gemini | +| Linux amd64 / arm64 | 1.48.0 | Claude, Codex, OpenCode, Gemini | +| Windows amd64 | 1.48.0 | Claude, Codex, OpenCode, Gemini | | macOS amd64 / arm64 | 1.46.1 | Claude, Codex, OpenCode | -Run the command once to migrate a normal Claude marketplace installation to its platform channel. Claude's plugin updater then follows that channel; rerun setup to update a standalone Codex bundle. Explicitly pinned/custom marketplace sources are retained. GitHub's global Latest remains 1.46.1 for older installers. See [platform channels](docs/PLATFORM_RELEASE_CHANNELS.md). +Run the command once to migrate a normal Claude marketplace installation to its platform channel. Claude's plugin updater then follows that channel; rerun setup to update a standalone Codex bundle. Explicitly pinned/custom marketplace sources are retained. GitHub's global Latest stays 1.46.1 for older installers. This release excludes macOS binaries and ClaudeNotifier.app; macOS retains its existing channel. See [platform channels](docs/PLATFORM_RELEASE_CHANNELS.md).

Non-interactive installation and optional notification tools @@ -98,9 +98,9 @@ Claude/Codex setup also installs the `agent-notify` MCP server and `agent-notifi | **OpenCode** | Root-session completion, questions, permissions and errors | No | [Setup and limits](docs/opencode-notifications.md) | | **Gemini CLI** | Turn completion and tool permissions | No | [Linux/Windows setup and qualification](docs/gemini-notifications.md) | -Published OpenCode support is tested with **1.18.33**; published V2 support is not declared. The dual-API candidate targets **1.18.33, 2.0.0 and 2.0.21** with one installed plugin; final platform qualification, SDK publication and a clean registry install remain pending. [Candidate setup and qualification limits](docs/opencode-notifications.md). Gemini is tested with **0.62.0**; a completed turn does not necessarily mean task success or a final answer. OpenCode/Gemini alerts require explicit desktop/webhook consent. Gemini alerts and OpenCode webhooks use generic text. OpenCode desktop alerts may include bounded native session titles and current question text. +Agent Notifications **1.48.0** supports OpenCode V1 and V2 with one installed plugin. Release qualification covers **V1 1.18.33 and V2 2.0.21** on Linux amd64/arm64 and Windows amd64, plus **V1 1.18.34 on Linux amd64**. The three Linux/Windows native artifacts have release canaries. macOS retains 1.46.1 and does not receive this dual-API update. The self-contained bundle incorporates SDK **0.3.0** from its reviewed vendored tarball; separate SDK publication is not required. [Setup, release reports and delivery limits](docs/opencode-notifications.md). Gemini is tested with **0.62.0**; a completed turn does not necessarily mean task success or a final answer. OpenCode/Gemini alerts require explicit desktop/webhook consent. Gemini alerts and OpenCode webhooks use generic text. OpenCode desktop alerts may include bounded native session titles and current question text. -For the OpenCode candidate on stock Windows V1, the original event age is not always independently verifiable. A delayed completion may notify once and recur after the 24-hour claim lifetime; filters, provenance, deduplication and limits still apply. +For OpenCode on stock Windows V1, the original event age is not always independently verifiable. A delayed completion may notify once and recur after the 24-hour claim lifetime; filters, provenance, deduplication and limits still apply. One-shot `opencode run` delivery at host shutdown is best effort; use a persistent host for sustained delivery. ## Settings diff --git a/bin/release_channel_test.sh b/bin/release_channel_test.sh index 943b37a4b..0593a9132 100755 --- a/bin/release_channel_test.sh +++ b/bin/release_channel_test.sh @@ -13,7 +13,7 @@ mkdir -p "$HOME" source "$ROOT/bin/release-channel.sh" for platform in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64; do selected=$(release_channel_select "$ROOT/release-channels.tsv" "${platform%/*}" "${platform#*/}") - case "$platform" in darwin/*) expected=v1.46.1 ;; *) expected=v1.47.1 ;; esac + case "$platform" in darwin/*) expected=v1.46.1 ;; *) expected=v1.48.1 ;; esac [ "${selected%%$'\t'*}" = "$expected" ] done for fault in duplicate missing invalid_sha invalid_tag ref schema unknown; do @@ -21,8 +21,8 @@ for fault in duplicate missing invalid_sha invalid_tag ref schema unknown; do case "$fault" in duplicate) tail -1 "$TEST_ROOT/index" >> "$TEST_ROOT/index" ;; missing) sed '$d' "$TEST_ROOT/index" > "$TEST_ROOT/new"; mv "$TEST_ROOT/new" "$TEST_ROOT/index" ;; - invalid_sha) sed 's/493d1a01a52761ddbc4bd3d712455767057dcf8d/main/g' "$TEST_ROOT/index" > "$TEST_ROOT/new"; mv "$TEST_ROOT/new" "$TEST_ROOT/index" ;; - invalid_tag) sed 's/v1.47.1/v01.47.1/g' "$TEST_ROOT/index" > "$TEST_ROOT/new"; mv "$TEST_ROOT/new" "$TEST_ROOT/index" ;; + invalid_sha) awk 'BEGIN { FS=OFS="\t" } NR==3 { $4="main" } { print }' "$TEST_ROOT/index" > "$TEST_ROOT/new"; mv "$TEST_ROOT/new" "$TEST_ROOT/index" ;; + invalid_tag) awk 'BEGIN { FS=OFS="\t" } NR==3 { $3="v01.48.0" } { print }' "$TEST_ROOT/index" > "$TEST_ROOT/new"; mv "$TEST_ROOT/new" "$TEST_ROOT/index" ;; ref) sed 's|release/platform-linux-windows|main|g' "$TEST_ROOT/index" > "$TEST_ROOT/new"; mv "$TEST_ROOT/new" "$TEST_ROOT/index" ;; schema) sed 's/channels-v1/channels-v2/' "$TEST_ROOT/index" > "$TEST_ROOT/new"; mv "$TEST_ROOT/new" "$TEST_ROOT/index" ;; unknown) printf 'linux\tppc64\tv1.47.1\tx\tx\tmain\n' >> "$TEST_ROOT/index" ;; diff --git a/docs/PLATFORM_RELEASE_CHANNELS.md b/docs/PLATFORM_RELEASE_CHANNELS.md index 8594b1336..0e477e632 100644 --- a/docs/PLATFORM_RELEASE_CHANNELS.md +++ b/docs/PLATFORM_RELEASE_CHANNELS.md @@ -6,10 +6,11 @@ release commit, an immutable source commit, and its platform branch. | Platform | Qualified version | Source branch | | --- | --- | --- | -| Linux amd64 / arm64, Windows amd64 | 1.47.1 | `release/platform-linux-windows` | +| Linux amd64 / arm64, Windows amd64 | 1.48.0 | `release/platform-linux-windows` | | macOS amd64 / arm64 | 1.46.1 | `release/platform-macos` | -GitHub Latest stays 1.46.1 for legacy installers. A platform channel can select +GitHub Latest stays 1.46.1 for legacy installers. Release 1.48.0 contains only +Linux and Windows artifacts; macOS retains its qualified 1.46.1 channel. A platform channel can select an explicitly qualified partial release even when GitHub labels it Pre-release. No scan for the newest tag or fallback to Latest is used. Invalid/missing rows, unsupported architectures and failed downloads stop selection before installation. diff --git a/docs/PLUGIN_COMPATIBILITY.md b/docs/PLUGIN_COMPATIBILITY.md index 5a2aab926..7a52a238e 100644 --- a/docs/PLUGIN_COMPATIBILITY.md +++ b/docs/PLUGIN_COMPATIBILITY.md @@ -5,11 +5,14 @@ Compatible with other Claude plugins that spawn background Claude instances. ## OpenCode OpenCode integration is a self-contained global plugin, separate from Claude -marketplace plugins and Codex hooks. Published support is tested with 1.18.33. The dual-API candidate is tested -with 1.18.33, 2.0.0 and 2.0.21; SDK 0.2.0 publication and registry qualification -are pending. Only generic root-session completion, question, permission and error -notifications are covered. It does not inherit portable MCP consent, sound or -click-to-focus behavior. Other global/project notification plugins and OpenCode's +marketplace plugins and Codex hooks. Agent Notifications 1.48.0 supports V1 +1.18.33 and V2 2.0.21 on Linux amd64/arm64 and Windows amd64, with V1 1.18.34 +also qualified on Linux amd64. The three Linux/Windows native artifacts have release canaries. +macOS retains 1.46.1 and its earlier OpenCode support. The self-contained bundle uses reviewed vendored SDK 0.3.0; +its separate npm publication is not a consumer release gate. Root-session completion, +question, permission and error notifications are covered; desktop alerts can use +bounded native session titles and current question text, while webhooks stay generic. +It does not inherit portable MCP consent, sound or click-to-focus behavior. Other global/project notification plugins and OpenCode's native desktop alerts can cause duplicates. Disable overlapping sources in the profile rather than installing this plugin twice. Owned or foreign plugin conflicts are preserved and reported. [Setup and compatibility limits](opencode-notifications.md). diff --git a/docs/RELEASE.md b/docs/RELEASE.md index fff7cc788..9284aac00 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -7,12 +7,22 @@ For a release that skips a platform, promote only its qualified rows and global Latest on the previously qualified version. Publish source branches before activating the immutable source SHAs in the channel index. +v1.48.1 is a Linux amd64/arm64 and Windows amd64 partial release. macOS stays +on v1.46.1, and GitHub global Latest stays v1.46.1. There are no Darwin binaries, +portable packages or ClaudeNotifier.app assets in v1.48.1. + +The earlier v1.48.0 draft and tag are retained unpublished: a repeated Windows +clock tick exposed a floating-point admission-budget bug. v1.48.1 fixes it +without extending the four-second limit; qualification must use the new binary. + ## 0. Pre-release risk checklist Run these checks for releases that touch the hook pipeline. -1. **Assets before the bump.** Follow the release-branch order in steps 4-5: tag and publish - assets first, land the bump on `main` last. Rationale in the callout under step 4. +1. **Assets before promotion.** Follow the release-branch order in steps 4-5: tag and publish + qualified assets first. Fully qualified all-platform releases land the version bump + on `main` last. Partial releases follow the platform-channel procedure; v1.48.1 + keeps all five legacy versions on `main` at 1.46.1. See the callout under step 4. 2. **Canary the draft binary** (step 5): `version` must print the new version, and synthetic Claude and Codex `Stop` payloads must reach local recording sinks. A binary that cannot report its version is the one failure the auto-updater cannot recover from. @@ -109,9 +119,11 @@ and their evidence on the release PR; the selector itself does not run or prove > > Prepare the bump on a release branch, tag that exact commit (`release.yml` triggers on the > tag, not on `main`), qualify the draft, follow the owner request scope below, -> publish its assets when authorized, and only then fast-forward -> `main` to the same SHA. The tag stays valid because the SHA is unchanged, and the -> asset-missing window is zero. +> publish its assets when authorized. Only a release qualified for every supported +> platform may fast-forward `main` to that same SHA. For partial v1.48.1, do not +> fast-forward the version bump to `main`: promote the Linux/Windows source and +> channel controller as described below, retaining all five legacy macOS versions. +> The native tag stays immutable and the asset-missing window is zero. ```bash git switch -c release/vX.Y.Z @@ -137,21 +149,51 @@ git push origin vX.Y.Z gh run watch # wait for release.yml to finish ``` -Before creating the draft, `release.yml` qualifies the actual downloaded binary artifacts -on all five native targets with `scripts/opencode-native-e2e.py` and the SHA-256-pinned -OpenCode 1.18.33 CLI, followed by the integrity-verified native npm package for -OpenCode 2.0.21. This checks completion webhook delivery, managed update and -revocation after removal in disposable projects and profiles. The uploaded -`opencode-release-e2e--` reports bind the evidence to the release commit -and binary SHA-256. These checks do not prove desktop banner delivery or the -Linux semantic matrix (questions, permission, errors, retry, cancellation, -compaction, child sessions and global reader ownership). - -The dual-API candidate consumes an exact reviewed UAP 0.2.0 tarball before -publication. Publish the SDK only with separate owner authorization, verify the -registry artifact against that tarball's integrity, and require a clean registry -install to reproduce the tested bundle before declaring the product merge/release -ready. Adding CI lanes does not establish that they have passed. +Before creating the v1.48.1 draft, `release.yml` checks the downloaded artifacts +on three native targets with `scripts/release-artifact-e2e.py`: Linux amd64/arm64 +and Windows amd64. The workflow has no macOS build, signing or helper upload jobs. +Artifact checks do not establish visible desktop banners. + +OpenCode qualification uses the same-run binaries in seven Linux/Windows cells: +V1 1.18.33 and V2 2.0.21 on Linux amd64/arm64 and Windows amd64, plus V1 1.18.34 +on Linux amd64. SHA-256-pinned host archives, native executable hashes, exact +release source and binary hashes bind the reports to the candidate. The checks +cover completion delivery, managed update and revocation in disposable projects. +They do not replace the broader semantic matrix or desktop delivery evidence. + +For immutable v1.48.1 artifacts, the `release-recovery` plan in +`opencode-native-e2e.yml` reuses the original release run rather than rebuilding. +Run `first-linux-amd64-v2` before `all-seven`. Fresh finite clock and packaged +reader observations must pass semantic validation before the unchanged installed +`business` suite runs. Publication requires successful reports for all seven +cells. This path does not qualify the full native clock, source epoch, time +policy, platform lifetime or visible desktop delivery; the full/smoke denial +gate remains unchanged. Retain the original failure and all raw recovery reports. +If only Windows fails, use `windows-two` to repeat those two cells. The Windows +recovery verifies the original executable's unique canonical LF bundle, then +uses the unchanged registration oracle to check installed raw bytes and the +real ownership hash after setup, update and reinstall. The original Windows +ACL helper uses verified prefetched modules in a private environment with external +Go module fetching disabled; this does not claim general outbound network isolation. +It must successfully set the protected DACL. These preparation receipts do not +qualify the full native gate. + +The v1.48.1 package qualification helper accepts lightweight release tags only; +annotated tags fail closed before any package execution. + +Same-run custody explicitly selects `--scope linux-windows` and seals only those +seven cells. The general fixture still requires all eleven cells by default; +partial custody rejects skipped-platform requests and missing requested binaries. +Neither this archive nor earlier macOS evidence qualifies a skipped platform. +Selected installed-client navigation and Windows interactive toast/cold callback +qualification remain incomplete; protocol and artifact checks do not close those gaps. + +The self-contained bundle incorporates UAP SDK 0.3.0 built from the reviewed +vendored tarball. Preparation verifies +its lockfile integrity and an identical rebuild of the tracked embedded bundle. +Publishing that separate SDK requires separate owner authorization; it is not a +prerequisite for this self-contained consumer release. Adding CI lanes does not +establish that they have passed. The workflow creates a **draft**, never an automatically published release. Inspect it with `gh release view vX.Y.Z --json isDraft,assets` and download the assets into a disposable @@ -171,8 +213,8 @@ both versions, so a binary that fails `version` leaves users stuck on it. After qualification, follow the owner's request for this release: - A request to **make a release** authorizes publishing the qualified version prepared for - that request. Publish the draft with `gh release edit vX.Y.Z --draft=false` without asking - for a second approval. State the candidate version in a progress update so the owner can + that request. Publish the draft with `gh release edit vX.Y.Z --draft=false --latest=false` + for a partial release without asking for a second approval. State the candidate version in a progress update so the owner can correct it before publication. - A request to **make a draft release** authorizes only the draft. Leave it unpublished until the owner asks to publish it. @@ -180,19 +222,52 @@ After qualification, follow the owner's request for this release: materially changed after the request, obtain explicit owner approval for the final version before publishing. Approval for an earlier release does not carry forward. -Verify the public assets and checksums, then land the exact same commit on `main`: +For v1.48.1, publish the qualified partial draft with: + +```bash +gh release edit v1.48.1 --draft=false --prerelease=false --latest=false +gh release view v1.48.1 --json isDraft,isPrerelease,assets +gh api repos/777genius/agent-notifications/releases/latest --jq .tag_name +# Latest must still be v1.46.1. +``` + +Verify public assets and checksums, then promote only the Linux/Windows platform +source branch and its immutable channel-index SHAs in the order documented in +[platform release channels](PLATFORM_RELEASE_CHANNELS.md). Preserve both macOS +rows and global Latest at v1.46.1. Do not use the general `main` promotion below +as a substitute for the partial platform promotion. + +For a fully qualified release across all published platforms, land the exact +same release commit on `main`: ```bash git switch main && git merge --ff-only release/vX.Y.Z && git push origin main ``` +## Partial releases without macOS + +When macOS signing is unavailable, qualify and publish only the Linux/Windows +assets with `--latest=false`. Keep the macOS channel and GitHub Latest at their +previous qualified version. Promote Linux/Windows source branches and immutable +source tags, then activate only their index rows as described in +[platform channels](PLATFORM_RELEASE_CHANNELS.md). + +Keep all five native version occurrences on `main` at the legacy macOS version. +Existing Claude marketplace users on `main` download the version in its manifest; +a newer version without Darwin assets would stall their updater. Integrate the +release and channel PR histories together with the main-version restoration in +one reviewed promotion, so no intermediate bump is published on `main`. The +release tag and Linux/Windows source retain their new version and exact source SHA. +The assets-before-main bump instructions above apply to a full-platform release. + ## ClaudeNotifier.app (macOS) -ClaudeNotifier.app is **automatically built, signed, and notarized** by the `release.yml` -workflow as a `build-notifier` job. It runs in parallel with Go binary builds and the -resulting `ClaudeNotifier.app.zip` is included in the same GitHub Release. +v1.48.1 does not build or publish ClaudeNotifier.app. macOS consumers retain the +signed and notarized helper from their qualified v1.46.1 channel. + +A future macOS release must restore and qualify its signing/notarization job; +the prior full-release workflow: -The CI workflow: 1. Imports the Apple Developer certificate from GitHub Secrets 2. Builds a universal binary (arm64 + x86_64) 3. Signs with **Developer ID Application** + hardened runtime diff --git a/docs/opencode-notifications.md b/docs/opencode-notifications.md index f3b1be71e..6c75e96a5 100644 --- a/docs/opencode-notifications.md +++ b/docs/opencode-notifications.md @@ -9,17 +9,19 @@ falls back to generic OpenCode copy. Alerts remain silent and do not navigate to a terminal or session when clicked. Plan/review events and sound controls are outside this integration. -The dual-API release candidate uses **one installed plugin** for OpenCode V1 and -[V2](https://opencode.ai/v2/docs): V1 calls `server`, V2 calls `setup`. Native -qualification targets are **1.18.33, 1.18.34 and 2.0.21**. The candidate installer +Agent Notifications **1.48.0** uses **one installed plugin** for OpenCode V1 and +[V2](https://opencode.ai/v2/docs): V1 calls `server`, V2 calls `setup`. Release +qualification targets are **V1 1.18.33, V1 1.18.34 and V2 2.0.21**. The installer accepts stable V1 >= 1.18.29 and V2 >= 2.0.0; this range does not qualify every release. Prereleases and unknown future API generations are rejected. Setup installs notifications, never OpenCode itself, and does not start an agent session. -**Publication pending:** this candidate requires the separately reviewed -`universal-agent-plugins-opencode-events@0.3.0` package. Qualification consumes its -exact local tarball; publishing that package and validating a clean registry install -are separate release steps. These changes do not upgrade an existing installation. +The self-contained plugin incorporates +`universal-agent-plugins-opencode-events@0.3.0` from the reviewed vendored tarball. +Release preparation verifies its exact archive, lock integrity and an identical +rebuild of the embedded bundle. The SDK is separately unpublished; publishing it +or installing it from the registry is not a gate for this consumer release. +Existing installations must run update and restart OpenCode to load the new bundle. ## Platforms and observed delivery @@ -28,34 +30,45 @@ For native Windows shell installation use **Git Bash**, not WSL or PowerShell. Desktop delivery uses the signed macOS helper, the Linux desktop notification service or Windows toasts. Linux needs an available desktop session/D-Bus service. -- The user confirmed a visible completion banner on macOS arm64. -- Linux amd64 X11/dunst rendered all four real OpenCode events; see the - [captured banners](evidence/opencode-1.18.33-x11-notifications.png). -- V1 native lifecycle/webhook checks passed on all five targets. Headless CI does - not establish visible macOS Intel, Linux ARM64 or Windows banners, or universal - compatibility with every desktop environment. -- Retained V2 native completion observations provide corroboration, but full - current-candidate qualification remains pending. Installed lifecycle and delivery - qualification are still owed on the native targets; workflow lanes alone do not - establish that they passed. V2 visible banners are not claimed. +- Historical macOS arm64 evidence includes a user-confirmed visible completion banner. +- Historical Linux amd64 X11/dunst evidence rendered all four real OpenCode events; + see the [captured banners](evidence/opencode-1.18.33-x11-notifications.png). +- Historical V1 lifecycle/webhook checks covered all five targets; retained V2 + native completion observations provide corroboration. They do not qualify new + release bytes by themselves. +- The 1.48.0 release gate covers seven native Linux/Windows cells and three + artifact canaries, as detailed below. Exact release reports establish which checks + passed; configured workflow lanes alone are not evidence. +- macOS binaries and the helper are excluded from 1.48.0; macOS retains 1.46.1. + Historical macOS ARM business evidence is separate from this release. Intel + desktop delivery and macOS V2 visible banners remain unqualified. **Windows V1 limitation:** stock OpenCode V1 events do not always allow the original event age to be verified independently. A delayed completion can notify once, and the same completion can notify again after its 24-hour deduplication claim expires. Root-session and workspace filters, origin-bound provenance, deduplication within that claim lifetime, and lookup and IPC limits still apply. -This accepted limitation does not qualify every Windows V1 version or establish -that the current candidate has completed native qualification. - -## Dual candidate evidence boundary - -The installed dual candidate fixture in `scripts/opencode-native-e2e.py` is source -preparation only. Its eleven native cells cover V1 1.18.33 and V2 2.0.21 on all -five platform pairs, plus Linux amd64 V1 1.18.34. No cell has been executed or -qualified by this preparation. Exact bundle/SDK/image custody, authoritative -managed configuration, production parent/profile and complete clock qualification -must precede delivery cases. Historical V1 evidence below does not qualify this -candidate or V2. Missing prerequisites report unqualified and stop business phases. +This accepted limitation does not qualify every Windows V1 version. + + + +## Release qualification boundary + +The installed fixture in `scripts/opencode-native-e2e.py` retains eleven custody +cells: V1 1.18.33 and V2 2.0.21 on all five platform pairs, plus Linux amd64 V1 +1.18.34. The 1.48.0 tag workflow runs seven Linux/Windows cells: both API versions +on Linux amd64/arm64 and Windows amd64, plus Linux amd64 V1 1.18.34. It separately +runs artifact canaries on the three Linux/Windows native targets. No macOS +binaries or helper ship in 1.48.0; macOS retains its 1.46.1 channel. These checks cover managed lifecycle, completion delivery and +revocation; they do not prove every desktop effect or replace the broader semantic +matrix. See the exact-source reports linked from the release and +[release procedure](RELEASE.md). + +Exact bundle/SDK/image custody, authoritative managed configuration, production +parent/profile and compiled clock qualification precede delivery cases. Missing +prerequisites report unqualified and stop business phases. Historical observations +below retain their original source and scope; they do not qualify a newer binary +or V2 by themselves. ## Install or update diff --git a/docs/qualification/codex-v1.48.0-linux-native-canary-2026-10-06.json b/docs/qualification/codex-v1.48.0-linux-native-canary-2026-10-06.json new file mode 100644 index 000000000..9e4fc8fbf --- /dev/null +++ b/docs/qualification/codex-v1.48.0-linux-native-canary-2026-10-06.json @@ -0,0 +1,76 @@ +{ + "schema": 1, + "purpose": "TEST D offline native Codex lifecycle evidence review", + "status": "native_stop_and_webhook_observed_offline", + "nativeRuntimeVersion": "codex-cli 0.159.2", + "sourceCommit": "2692f443a3834c6e4090176d478e69faa684bca0", + "sourceArchiveSHA256": "cd6c61c718de90534ea97d249f2eb9e8c430e17821c126c0d9e9cd9ab16a34af", + "binarySHA256": "e05c07918b7b92fda7c71a081893274eab50c100323fc2d64cfe844a19482db1", + "installedBinarySHA256": "e05c07918b7b92fda7c71a081893274eab50c100323fc2d64cfe844a19482db1", + "executedHarnessSHA256": "649ca105c10900d6bc7b197c8d22d9907b7a8ffc2956d650591bb832e24f18e3", + "wrapperSHA256": "b7eca82e28ec9baefa62a2ecd8c6b6883ca5fa1587f296d150ac43c1e2ffae9e", + "custodyBasis": "trusted operator SHA256 capture; local archive hash and Git archive commit independently checked", + "nativeThread": "01a1121d-43b4-7343-9c52-fc722f43cf4e", + "nativeProject": "/tmp/TEST-an-v1480-codex-native-20261006-D/TEST-project", + "nonce": "TEST_AN_NATIVE_STOP_e715b9e83bc33494d347", + "nativeStopCount": 1, + "correlatedWebhookCount": 1, + "completedNativeTurns": 1, + "loopbackModelRequests": 1, + "actualToolCalls": 0, + "advertisedHarmlessBuiltins": [ + "create_goal", + "get_goal", + "request_user_input", + "update_goal" + ], + "nativeHookTrustObserved": true, + "diagnosticWarnings": { + "ignoredSettings": [ + "model_max_output_tokens", + "tools.view_image" + ], + "duplicateConfigWarningCount": 2, + "fallbackModelMetadataWarningCount": 1 + }, + "originalHarnessResult": { + "status": "unqualified", + "reason": "AssertionError [ERR_ASSERTION]: native_tool_execution_item_forbidden", + "sha256": "f688fd1e8e3139e91972886374b4b0a2b4a7e462f97e0bdb834787c447b6f026", + "unchanged": true + }, + "harnessFalseNegative": "error items are known configuration warnings, not tool executions; webhook sanitizes underscores", + "retainedClaudeConfigObserved": true, + "repeatedSetupOutcome": "preceding harness assertions passed; no separate before/after receipt retained", + "allProcessCleanupQualified": false, + "nativeSessionTranscriptIndependentlyChecked": false, + "liveProviderQualified": false, + "visibleDesktopQualified": false, + "fullReleaseQualified": false, + "limitations": [ + "Offline review of one completed turn, not a successful harness rerun.", + "Installed binary and wrapper hashes rely on the trusted operator capture, not downloaded executable bytes.", + "No independent process tree closure, quiet-tail duplicate observation, native session transcript or desktop result proof." + ], + "rawEvidenceSHA256": { + "phase.json": "0380e25d51fa21aa30785cd1796a4d09c9e8a137f373b907c658053275fd1cb1", + "result.json": "f688fd1e8e3139e91972886374b4b0a2b4a7e462f97e0bdb834787c447b6f026", + "network-custody.json": "4d55b87f978360dedb1bbfa98acd7c38f6fcfb05afc1abbb8a001bcebfc5baf4", + "native-turn.jsonl": "068e75b56c5e1c1aa3a780fbc01b22dccf56676f3a6a18742bef6033c78cda0a", + "native-stop.jsonl": "4654998f09ebbdcbe913d8754f856cf9fb96ccc98908b4d9f5bb64c31cac34cf", + "webhooks.json": "8d35902d1450861ec668e6fcd9aea330796dcb44d6b876239e469a4b9f44f185", + "responses-request.json": "8195175b7cba4db88ccf18566f99bc4d22092cf942359aea9df6556a65a78be5", + "native-hook-trust.json": "30799e12a9eb9b0359278473f746c463c54c083071eda9392fbf9aabc03e3a97", + "native-codex-version.txt": "48784a39efd6841c30aff4c70ab315bea1dccee48519515cb308652cdef3ea57", + "trust-pty.raw": "82a8b05705e8baa06449aac738f2f43a45fbbe52c78776847a60eab11326d764", + "trust-screen.txt": "e3b28671d76fbc6eb4879bab2a8a34dd0f0b98906d784741e3542b63a09532f8", + "codex-home/hooks.json": "081c470309ed62a981abc1b765cc493e3aeaefb6500f42f7f4a2131e520c485e", + "codex-home/config.toml": "54f92beb8b4c0615916031817ff994e0222574c9e2066f073e6e3add8ddd0f92", + "claude_config_dir/settings.json": "5be4259cc90d302881cdfdc5bf36144e6c4a95af2729e295d6dd1db967c4a0e3", + "an-config.json": "d97ba911daa05598b4df2790add0ae930651c1bc3324c54fda5c15d503e66d49", + "operator-sha256.txt": "e33fe44a9b062565ef73d21be21697257280d26f2c4e0804fe0c631cb7cd85e2", + "evidence.tar": "3a249610c5435542ed8e4e68c031e4906cb57b85234ee9529f74c7aba1753084", + "config-evidence.tar": "f148e831b9d38c4dfcd44dd1c1f0e33871922fa73695bc8c0e106d7b3a26b6a2" + }, + "verifierSHA256": "233a6aab76c5cd7b189b18af10ff4d78df19d276098cb424dc5782b33048c2ed" +} diff --git a/internal/notification/observation/budget.go b/internal/notification/observation/budget.go index 8c7d04326..49239ee50 100644 --- a/internal/notification/observation/budget.go +++ b/internal/notification/observation/budget.go @@ -44,10 +44,12 @@ func Remaining(clock Clock, deadline notification.Deadline) (time.Duration, bool } boot, now, err := clock.Now() seconds := deadline.NotAfter - now - if !validTime(boot, now, err) || boot != deadline.BootID || math.IsNaN(seconds) || math.IsInf(seconds, 0) || seconds <= 0 || seconds > TotalBudget.Seconds() { + // Use the same absolute bound as Admission: subtracting across a float64 + // precision boundary can round a freshly admitted four seconds upward. + if !validTime(boot, now, err) || boot != deadline.BootID || math.IsNaN(seconds) || math.IsInf(seconds, 0) || seconds <= 0 || deadline.NotAfter > now+TotalBudget.Seconds() { return 0, false } - return time.Duration(seconds * float64(time.Second)), true + return time.Duration(math.Min(seconds, TotalBudget.Seconds()) * float64(time.Second)), true } // WatchBudget cancels on parent timeout or continuous-clock expiry/failure. diff --git a/internal/notification/observation/budget_test.go b/internal/notification/observation/budget_test.go new file mode 100644 index 000000000..4a1e0dc3c --- /dev/null +++ b/internal/notification/observation/budget_test.go @@ -0,0 +1,96 @@ +package observation + +import ( + "context" + "errors" + "math" + "testing" + "time" + + "github.com/777genius/agent-notifications/internal/notification" +) + +type budgetClock struct { + boot string + seconds float64 + err error +} + +func (c budgetClock) Now() (string, float64, error) { + return c.boot, c.seconds, c.err +} + +// A repeated Windows clock tick must retain its freshly admitted budget, even +// when adding four seconds crosses a float64 precision boundary. At long +// uptimes the subtraction can round upward by more than a nanosecond. +func TestRemainingAdmissionBudgetRepeatedTick(t *testing.T) { + for _, sample := range []struct { + name string + seconds int64 + nanoseconds int64 + }{ + {"short uptime", 511, 100100}, + {"long uptime", 16777215, 100000}, + } { + t.Run(sample.name, func(t *testing.T) { + clock := budgetClock{boot: "same-boot", seconds: float64(sample.seconds) + float64(sample.nanoseconds)/1e9} + _, deadline, cancel, err := Admission(context.Background(), clock) + if err != nil { + t.Fatal(err) + } + defer cancel() + remaining, ok := Remaining(clock, deadline) + if !ok || remaining != 4*time.Second { + t.Fatalf("fresh admission on repeated tick: remaining=%v accepted=%v", remaining, ok) + } + }) + } +} + +func TestRemainingRejectsInvalidDeadline(t *testing.T) { + good := budgetClock{boot: "same-boot", seconds: 100} + deadline := notification.Deadline{BootID: good.boot, NotAfter: 104} + for _, test := range []struct { + name string + clock Clock + deadline notification.Deadline + }{ + {"nil clock", nil, deadline}, + {"clock error", budgetClock{boot: good.boot, seconds: 100, err: errors.New("unavailable")}, deadline}, + {"empty boot", budgetClock{seconds: 100}, deadline}, + {"changed boot", budgetClock{boot: "other-boot", seconds: 100}, deadline}, + {"negative clock", budgetClock{boot: good.boot, seconds: -1}, deadline}, + {"nan clock", budgetClock{boot: good.boot, seconds: math.NaN()}, deadline}, + {"infinite clock", budgetClock{boot: good.boot, seconds: math.Inf(1)}, deadline}, + {"expired", good, notification.Deadline{BootID: good.boot, NotAfter: 100}}, + {"past", good, notification.Deadline{BootID: good.boot, NotAfter: 99}}, + {"nan deadline", good, notification.Deadline{BootID: good.boot, NotAfter: math.NaN()}}, + {"infinite deadline", good, notification.Deadline{BootID: good.boot, NotAfter: math.Inf(1)}}, + {"negative infinite deadline", good, notification.Deadline{BootID: good.boot, NotAfter: math.Inf(-1)}}, + {"forged extra budget", good, notification.Deadline{BootID: good.boot, NotAfter: math.Nextafter(104, math.Inf(1))}}, + {"clock moved backward", budgetClock{boot: good.boot, seconds: 99.9999999}, deadline}, + } { + t.Run(test.name, func(t *testing.T) { + if remaining, ok := Remaining(test.clock, test.deadline); ok || remaining != 0 { + t.Fatalf("invalid deadline accepted: remaining=%v accepted=%v", remaining, ok) + } + }) + } +} + +func TestRemainingDecreasesWithElapsedTime(t *testing.T) { + deadline := notification.Deadline{BootID: "same-boot", NotAfter: 104} + for _, test := range []struct { + now float64 + want time.Duration + }{ + {100, 4 * time.Second}, + {101.25, 2750 * time.Millisecond}, + {103.5, 500 * time.Millisecond}, + } { + remaining, ok := Remaining(budgetClock{boot: deadline.BootID, seconds: test.now}, deadline) + if !ok || remaining != test.want { + t.Fatalf("now=%v remaining=%v accepted=%v want=%v", test.now, remaining, ok, test.want) + } + } +} diff --git a/internal/opencodeevent/admission_revision_test.go b/internal/opencodeevent/admission_revision_test.go index 44247b282..4240bb611 100644 --- a/internal/opencodeevent/admission_revision_test.go +++ b/internal/opencodeevent/admission_revision_test.go @@ -98,6 +98,20 @@ func TestAdmissionNativePolicyGoldenAndProgressBoundary(t *testing.T) { func TestAdmissionNativeRetentionBoundary(t *testing.T) { ctx, a, r, c := admissionFixture(t) qualifiedFixture(&a, &r, c) + // Each admission is a separate command; setup and prior assertions must + // not consume its budget. Snapshot reads also have their own live context. + admit := func(want AdmissionStatus) { + commandCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + request := r + request.CommandStarted = time.Now() + tryAdmission(t, commandCtx, a, request, want) + } + keys := func() []string { + readCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return retainedKeys(t, readClaimFixture(t, readCtx, a, r)) + } rows := make([]map[string]any, 4096) for i := range rows { key := sha256.Sum256([]byte{byte(i >> 8), byte(i)}) @@ -116,15 +130,15 @@ func TestAdmissionNativeRetentionBoundary(t *testing.T) { c.sample.TickNS += int64(24*time.Hour) + 205_999_999 c.sample.WallNS += int64(24*time.Hour) + 205_999_999 r.Provenance = freshProvenance(c.sample) - tryAdmission(t, ctx, a, r, Capacity) - if len(retainedKeys(t, readClaimFixture(t, ctx, a, r))) != 4096 { + admit(Capacity) + if len(keys()) != 4096 { t.Fatal("early native pruning") } c.sample.TickNS++ c.sample.WallNS++ r.Provenance = freshProvenance(c.sample) - tryAdmission(t, ctx, a, r, Admitted) - if len(retainedKeys(t, readClaimFixture(t, ctx, a, r))) != 1 { + admit(Admitted) + if len(keys()) != 1 { t.Fatal("verified native capacity did not recover") } } diff --git a/landing/locales/ar.json b/landing/locales/ar.json index 9de25ad5e..9ba70d073 100644 --- a/landing/locales/ar.json +++ b/landing/locales/ar.json @@ -145,7 +145,7 @@ "gemini": "Gemini CLI (Linux / Windows): تنبيهات صامتة لانتهاء الدور وطلب إذن الأداة. انتهاء الدور لا يعني النجاح أو الإجابة النهائية. لا صوت أو تركيز بالنقر أو تنبيهات للأسئلة والأخطاء والخطط والمراجعات أو تكافؤ للوكلاء المتداخلين." }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini غير متاح)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini غير متاح)", "guide": "إعداد Gemini وحدوده", "configure": "راجع دليل Gemini للموافقة على القنوات والإعدادات المشتركة؛ لا تُثبّت أداة MCP أو مهارة. قد تتكرر التنبيهات المدمجة. اختر مصدرًا واحدًا لسطح المكتب أو webhooks فقط؛ لا تتغير الإعدادات المدمجة." }, diff --git a/landing/locales/de.json b/landing/locales/de.json index 7a8c94ee3..d299c3730 100644 --- a/landing/locales/de.json +++ b/landing/locales/de.json @@ -28,7 +28,7 @@ "gemini": "Gemini CLI (Linux / Windows): lautlose Meldungen bei abgeschlossenem Dialogschritt und Werkzeugfreigabe. Ein abgeschlossener Schritt bedeutet weder Erfolg noch endgültige Antwort. Kein Ton, Klickfokus, Fragen-, Fehler-, Plan-, Review- oder gleichwertiger Unteragenten-Support." }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini nicht verfügbar)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini nicht verfügbar)", "guide": "Gemini: Einrichtung und Grenzen", "configure": "Kanalzustimmung und gemeinsame Einstellungen stehen im Gemini-Leitfaden; kein MCP-Werkzeug oder Skill. Integrierte Meldungen können doppelt erscheinen. Eine Desktop-Quelle oder nur Webhooks wählen; integrierte Einstellungen bleiben erhalten." }, diff --git a/landing/locales/en.json b/landing/locales/en.json index a42dad8fd..d9974675c 100644 --- a/landing/locales/en.json +++ b/landing/locales/en.json @@ -292,7 +292,7 @@ "gemini": "Gemini CLI (Linux / Windows): silent turn-completed and tool-permission alerts. A completed turn does not imply success or a final answer. No sound, click-to-focus, question, error, plan, review or nested-agent parity." }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini unavailable)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini unavailable)", "guide": "Gemini setup and limits", "configure": "Use the Gemini guide for channel consent and shared settings; no MCP tool or skill is installed. Built-in Gemini notifications can duplicate desktop alerts. Choose one desktop source or webhook-only; built-in settings are not changed." }, diff --git a/landing/locales/es.json b/landing/locales/es.json index 092513fe7..e4a56f273 100644 --- a/landing/locales/es.json +++ b/landing/locales/es.json @@ -44,7 +44,7 @@ "gemini": "Gemini CLI (Linux / Windows): avisos silenciosos de turno terminado y permiso de herramientas. Terminar un turno no implica éxito ni respuesta final. Sin sonido, enfoque al pulsar, preguntas, errores, planes, revisiones ni paridad para agentes anidados." }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini no disponible)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini no disponible)", "guide": "Configuración y límites de Gemini", "configure": "Consulta la guía Gemini para consentimiento de canales y ajustes compartidos; no se instala MCP ni skill. Los avisos integrados pueden duplicarse. Elige una fuente de escritorio o solo webhooks; los ajustes integrados no se modifican." }, diff --git a/landing/locales/fr.json b/landing/locales/fr.json index 56744e284..77ec41b32 100644 --- a/landing/locales/fr.json +++ b/landing/locales/fr.json @@ -28,7 +28,7 @@ "gemini": "Gemini CLI (Linux / Windows) : alertes silencieuses de tour terminé et de permission d’outil. Un tour terminé ne signifie ni réussite ni réponse finale. Sans son, focus au clic, questions, erreurs, plans, revues ni parité pour les agents imbriqués." }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini indisponible)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini indisponible)", "guide": "Configuration et limites de Gemini", "configure": "Consultez le guide Gemini pour le consentement aux canaux et les réglages partagés ; aucun outil MCP ni skill installé. Les alertes intégrées peuvent faire doublon. Choisissez une source bureau ou uniquement les webhooks ; les réglages intégrés restent inchangés." }, diff --git a/landing/locales/hi.json b/landing/locales/hi.json index 8692830b0..65e586814 100644 --- a/landing/locales/hi.json +++ b/landing/locales/hi.json @@ -145,7 +145,7 @@ "gemini": "Gemini CLI (Linux / Windows): टर्न पूरा होने और टूल अनुमति के लिए मौन सूचनाएँ। टर्न पूरा होने का अर्थ सफलता या अंतिम उत्तर नहीं है। ध्वनि, क्लिक से फ़ोकस, प्रश्न, त्रुटि, योजना, समीक्षा सूचनाएँ या नेस्टेड एजेंट समानता नहीं।" }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini उपलब्ध नहीं है।)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini उपलब्ध नहीं है।)", "guide": "Gemini सेटअप और सीमाएँ", "configure": "चैनल सहमति और साझा सेटिंग के लिए Gemini गाइड देखें; MCP टूल या स्किल इंस्टॉल नहीं होता। अंतर्निहित सूचनाएँ दोहर सकती हैं। एक डेस्कटॉप स्रोत या केवल webhook चुनें; अंतर्निहित सेटिंग नहीं बदलतीं।" }, diff --git a/landing/locales/it.json b/landing/locales/it.json index fc6351092..0ff3da88e 100644 --- a/landing/locales/it.json +++ b/landing/locales/it.json @@ -145,7 +145,7 @@ "gemini": "Gemini CLI (Linux / Windows): avvisi silenziosi di turno concluso e permesso per strumenti. Un turno concluso non implica successo né risposta finale. Nessun suono, focus al clic, domanda, errore, piano, revisione o parità per agenti annidati." }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini non disponibile)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini non disponibile)", "guide": "Configurazione e limiti di Gemini", "configure": "Consulta la guida Gemini per consenso ai canali e impostazioni condivise; nessun MCP o skill installato. Gli avvisi integrati possono duplicarsi. Scegli una fonte desktop o solo webhook; le impostazioni integrate non vengono modificate." }, diff --git a/landing/locales/ja.json b/landing/locales/ja.json index 3788cdd47..186b93bf9 100644 --- a/landing/locales/ja.json +++ b/landing/locales/ja.json @@ -28,7 +28,7 @@ "gemini": "Gemini CLI (Linux / Windows):ターン終了とツール許可要求を無音で通知します。ターン終了は成功や最終回答を意味しません。音、クリックでのフォーカス、質問、エラー、計画、レビュー通知や入れ子エージェントの同等対応はありません。" }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini は利用できません。)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini は利用できません。)", "guide": "Gemini の設定と制限", "configure": "通知経路の同意と共通設定は Gemini ガイドをご覧ください。MCP ツールやスキルは追加しません。内蔵通知と重複する場合はデスクトップ通知元を一つにするか webhook のみを選択してください。内蔵設定は変更しません。" }, diff --git a/landing/locales/ko.json b/landing/locales/ko.json index f6abcc87d..b8de7784b 100644 --- a/landing/locales/ko.json +++ b/landing/locales/ko.json @@ -28,7 +28,7 @@ "gemini": "Gemini CLI (Linux / Windows): 턴 완료와 도구 권한 요청을 무음으로 알립니다. 턴 완료는 성공이나 최종 답변을 뜻하지 않습니다. 소리, 클릭 포커스, 질문, 오류, 계획, 검토 알림 및 중첩 에이전트 기능 동등성은 지원하지 않습니다." }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini 사용 불가)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini 사용 불가)", "guide": "Gemini 설정 및 제한", "configure": "채널 동의와 공유 설정은 Gemini 안내를 참고하세요. MCP 도구나 스킬을 설치하지 않습니다. 내장 알림과 중복될 수 있습니다. 데스크톱 알림 소스 하나 또는 webhook 전용을 선택하세요. 내장 설정은 변경하지 않습니다." }, diff --git a/landing/locales/pt.json b/landing/locales/pt.json index f2c82c45b..2a350253a 100644 --- a/landing/locales/pt.json +++ b/landing/locales/pt.json @@ -28,7 +28,7 @@ "gemini": "Gemini CLI (Linux / Windows): alertas silenciosos de turno concluído e permissão de ferramenta. Concluir um turno não implica sucesso nem resposta final. Sem som, foco ao clicar, perguntas, erros, planos, revisões ou paridade para agentes aninhados." }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini indisponível)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini indisponível)", "guide": "Configuração e limites do Gemini", "configure": "Consulte o guia Gemini para consentimento dos canais e configurações compartilhadas; nenhum MCP ou skill instalado. Os alertas integrados podem duplicar. Escolha uma fonte desktop ou somente webhooks; as configurações integradas não são alteradas." }, diff --git a/landing/locales/ru.json b/landing/locales/ru.json index 9aeef2f62..f7285263f 100644 --- a/landing/locales/ru.json +++ b/landing/locales/ru.json @@ -28,7 +28,7 @@ "gemini": "Gemini CLI (Linux / Windows): тихие уведомления о завершении хода и запросе разрешения на инструмент. Завершённый ход не означает успех или окончательный ответ. Без звука, фокуса по клику и уведомлений о вопросах, ошибках, планах, ревью или паритета вложенных агентов." }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini недоступен)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini недоступен)", "guide": "Настройка Gemini и ограничения", "configure": "Настройки каналов и общую конфигурацию смотрите в руководстве Gemini; MCP и skill не устанавливаются. Встроенные уведомления могут дублироваться. Выберите один источник desktop или только webhook; встроенные настройки не меняются." }, diff --git a/landing/locales/zh.json b/landing/locales/zh.json index a6ea441d7..f958ba2a9 100644 --- a/landing/locales/zh.json +++ b/landing/locales/zh.json @@ -321,7 +321,7 @@ "gemini": "Gemini CLI (Linux / Windows):静默提示回合结束和工具权限请求。回合结束不代表成功或最终答复。不支持声音、点击聚焦、问题、错误、计划、审查通知或嵌套代理功能对等。" }, "gemini": { - "version": "Linux / Windows: 1.47.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini 不可用。)", + "version": "Linux / Windows: 1.48.1 (Gemini CLI 0.62.0). macOS: 1.46.1 (Claude, Codex CLI, OpenCode; Gemini 不可用。)", "guide": "Gemini 设置与限制", "configure": "渠道授权和共享设置请参阅 Gemini 指南;不安装 MCP 工具或技能。内置通知可能重复。请选择一个桌面通知来源或仅使用 webhook;不会修改内置设置。" }, diff --git a/landing/tests/browser/install.spec.ts b/landing/tests/browser/install.spec.ts index 93fb233e1..43edf1c51 100644 --- a/landing/tests/browser/install.spec.ts +++ b/landing/tests/browser/install.spec.ts @@ -646,7 +646,7 @@ test("manual instructions preserve legacy links and offer Gemini guidance", asyn await expect(page.getByRole("button", { name: "Gemini CLI", exact: true })).toBeEnabled(); await chooseAgents(page, ["gemini"]); await expect(manual.getByRole("link", { name: "Gemini setup and limits", exact: true })).toBeVisible(); - await expect(manual).toContainText("Linux / Windows: 1.47.1"); + await expect(manual).toContainText("Linux / Windows: 1.48.1"); await expect(page.getByLabel(intent + " command", { exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Copy command" })).toHaveCount(0); } @@ -685,7 +685,7 @@ test("platform channels enable Gemini and reset unsupported selections", async ( await page.goto(""); for (const os of ["linux", "windows"] as const) { await chooseOS(page, os); - await expect(page.locator(".install-release-version")).toContainText("1.47.1"); + await expect(page.locator(".install-release-version")).toContainText("1.48.1"); await expect(page.getByRole("button", { name: "Gemini CLI", exact: true })).toBeEnabled(); await chooseAgents(page, ["gemini"]); await expect(page.getByLabel("Install command", { exact: true })).toHaveValue(/--product gemini --desktop$/); diff --git a/opencode-plugin/README.md b/opencode-plugin/README.md index a58dec753..da26918f7 100644 --- a/opencode-plugin/README.md +++ b/opencode-plugin/README.md @@ -1,9 +1,12 @@ # OpenCode plugin bundle -This candidate consumes the actual **unpublished 0.3.0** npm pack at +The Agent Notifications 1.48.0 bundle consumes the actual **unpublished 0.3.0** npm pack at `vendor/universal-agent-plugins-opencode-events-0.3.0.tgz`, with the genuine npm-generated relative file lock. The published 0.2.0 root API stays in the SDK; -AN imports the strict `/v1` and `/v2` factories. esbuild remains 0.28.2. +AN imports the strict `/v1` and `/v2` factories. The built ESM bundle is self-contained; +separate SDK publication is not a consumer release gate. esbuild remains 0.28.2. +The reviewed archive SHA-256 is +`c3d5aaaf6ecc3116b48ab1ae3f0e00b47720f239df9938f0c499d03f2c21a752`. After the parent provisions Node/npm and bootstraps the locked dependencies, `npm run check:candidate` byte-verifies the archive, installed packed files and @@ -30,8 +33,8 @@ The default export is exactly `{id, server, setup}`. The retained named `AgentNotifications(input)` routes through the same memoized V1 startup as `default.server(input)`: discovering both exports cannot acquire two registries or observers. V2 uses its direct native subscription and public RPC source path. -These loader shapes describe candidate source; installed delivery still requires -qualification. +These loader shapes serve both API generations through one installed plugin. +Installed delivery remains bounded by exact-source qualification and host lifetime. The V1 source requires a live observed user/ordinary assistant association and fresh root/location metadata. Idle alone, retry, interruption and a manual summary @@ -41,9 +44,9 @@ V2 binds the original execution-start and terminal envelope identities, validate native session context and pending attention, and consumes its final checkpoint on the same reader. Queued input fences obsolete completion; HTTP prompt admission alone does not establish a settled execution. Fork lineage is distinct from true -child ancestry. These behaviors have focused source tests, with installed/native -qualification still pending. Restart the host after install or update to load the -reviewed candidate; reload support alone is not a delivery qualification. +child ancestry. These behaviors have focused source tests; release reports bind installed/native +checks to the released bytes. Restart the host after install or update to load the +reviewed bundle; reload support alone is not a delivery qualification. The renderer must replace exactly one quoted token each for executable, control root and origin. Origin comes from the existing E1 registration; JS @@ -51,11 +54,12 @@ never creates or repairs it. One max-four registry holds profile probes at weight two and clock/event children at weight one through actual close. Missing inner reaping proof permanently retains both profile reservations. -Production clock selection is **unverified**. `createLinuxClock()` exposes the +Production clock selection uses reviewed compiled qualification rows, bound to +the exact host image and semantic source. `createLinuxClock()` exposes the actual synchronous proc reader for parent qualification; describing a closed manifest cannot register it. Only compiled reviewed evidence may populate `clock-qualification-data.mjs`, with the same independently selected Go policy. The fixed -candidate budget is R=103ms/T=430ms, not a measured maximum or today's grant. +policy budget is R=103ms/T=430ms, not a measured maximum or a runtime grant. Private protocol 1 wraps unchanged neutral wire 1. Native IDs/timestamps and original source ingress/anchor/deadline remain private; outbound delivery is @@ -68,14 +72,21 @@ The installed fixture retains the SOURCE13 driver and eleven custody cells: V1 1.18.33 and V2 2.0.21 on all five platform pairs, plus V1 1.18.34 on Linux amd64. It stages completion, pending question/permission, permanent error, retry/interrupt, manual compaction, foreground child, independent roots/fork and install/update/ -remove/reinstall scenarios. Its production clock pre-model stop remains explicit. -Inert fixture checks establish source/parser contracts only; all installed cells, -product final settlement, durable claims and owned child closure remain unproved. +remove/reinstall scenarios. Missing custody or production clock prerequisites stop +business phases. Inert fixture checks establish source/parser contracts only. -The committed generated asset is deliberately retained at its existing three -quoted executable/root/origin placeholders. It has not been rebuilt for the -current clock source and pending SDK corrections. Root must integrate the separate -SDK V1 fix and V2 RPC schema correction, repack the SDK and update its metadata -and lock together, then deterministically rebuild and review the embedded asset. -Until that work is complete, this source merge makes no artifact-coherence, -SDK publication or fully supported delivery claim. +The 1.48.0 tag release gate runs seven Linux/Windows cells: V1 1.18.33 and V2 +2.0.21 on Linux amd64/arm64 and Windows amd64, plus Linux amd64 V1 1.18.34. +The three Linux/Windows native artifacts retain canaries. macOS artifacts and +the signed/notarized helper are excluded from 1.48.0. Exact release reports, rather than configured lanes, +establish successful qualification. Intel macOS desktop delivery remains +unqualified; historical macOS ARM business evidence is separate. One-shot host +shutdown remains best effort. Stock Windows V1 may notify a delayed completion +once and again after the 24-hour claim expires when original event age cannot be +independently verified. + +The committed generated asset retains its three quoted executable/root/origin +placeholders. Release preparation verifies the reviewed SDK archive and lock, +rebuilds the bundle and requires byte equality with that tracked embedded asset. +This consumer verification does not publish the standalone SDK or grant eligibility +to an unqualified host. See [release qualification boundaries](../docs/opencode-notifications.md#release-qualification-boundary). diff --git a/release-channels.tsv b/release-channels.tsv index ad80f83f3..3ed557387 100644 --- a/release-channels.tsv +++ b/release-channels.tsv @@ -2,6 +2,6 @@ # os arch tag release_commit source_commit source_ref darwin amd64 v1.46.1 01c836354f55f7e741ca1bd1702da8d7e049f0ea aca63ba577d19835f1c68d0af4fba0c6e573696a release/platform-macos darwin arm64 v1.46.1 01c836354f55f7e741ca1bd1702da8d7e049f0ea aca63ba577d19835f1c68d0af4fba0c6e573696a release/platform-macos -linux amd64 v1.47.1 493d1a01a52761ddbc4bd3d712455767057dcf8d 478f1abe1827297e91db3aea7e9c145b14f9b15a release/platform-linux-windows -linux arm64 v1.47.1 493d1a01a52761ddbc4bd3d712455767057dcf8d 478f1abe1827297e91db3aea7e9c145b14f9b15a release/platform-linux-windows -windows amd64 v1.47.1 493d1a01a52761ddbc4bd3d712455767057dcf8d 478f1abe1827297e91db3aea7e9c145b14f9b15a release/platform-linux-windows +linux amd64 v1.48.1 e86149a724f9b96cb5ee193d63dbc5a533c61e7e 65a28229e223437a12bb3afd8497a90700744847 release/platform-linux-windows +linux arm64 v1.48.1 e86149a724f9b96cb5ee193d63dbc5a533c61e7e 65a28229e223437a12bb3afd8497a90700744847 release/platform-linux-windows +windows amd64 v1.48.1 e86149a724f9b96cb5ee193d63dbc5a533c61e7e 65a28229e223437a12bb3afd8497a90700744847 release/platform-linux-windows diff --git a/scripts/opencode-native-e2e.py b/scripts/opencode-native-e2e.py index 9c9931f10..6a3d0ed49 100644 --- a/scripts/opencode-native-e2e.py +++ b/scripts/opencode-native-e2e.py @@ -33,6 +33,7 @@ ("darwin", "arm64"), ("windows", "amd64")) CELLS = tuple((o, a, v) for o, a in PLATFORMS for v in VERSIONS if v != "1.18.34" or (o, a) == ("linux", "amd64")) +RELEASE_CELLS = tuple(cell for cell in CELLS if cell[0] != 'darwin') COPY = {"completion": ("Task completed", "task_complete"), "form": ("OpenCode asked a question", "question"), "permission": ("OpenCode requested permission", "permission_request"), @@ -180,6 +181,22 @@ def verify_source_binding(m, checkout, build, dirty): and re.search(r'(?m)^\s*build\s+vcs.modified=false\s*$', build), 'exact_clean_candidate_build_required') +def custody_cells(manifest, requested): + """A partial release is explicit custody scope, never a qualification grant.""" + if 'releaseScope' in manifest: + require(manifest['releaseScope'] == 'linux-windows', 'unsupported_release_scope') + expected = RELEASE_CELLS + else: + expected = CELLS + require(isinstance(manifest['cells'], list) and len(manifest['cells']) == len(expected), + 'seven_release_cells_required' if 'releaseScope' in manifest else 'eleven_native_cells_required') + require(all(isinstance(cell, dict) for cell in manifest['cells']), 'cell_schema') + identities = [(cell.get('os'), cell.get('arch'), cell.get('version')) for cell in manifest['cells']] + require(len(set(identities)) == len(identities) and set(identities) == set(expected), 'matrix_incomplete') + require(requested in expected, 'requested_cell_outside_custody_scope') + return expected + + def load_manifest(path, os_name, arch, version, expected_sha256=None): input_path = path.absolute() path = path.resolve(strict=True) @@ -191,7 +208,8 @@ def pairs(items): out[key] = value return out m = json.loads(path.read_text(), object_pairs_hook=pairs) - closed(m, ('schema', 'purpose', 'candidateCommit', 'buildRevision', 'assets', 'sdk', 'cells'), 'manifest_schema') + keys = ('schema', 'purpose', 'candidateCommit', 'buildRevision', 'assets', 'sdk', 'cells') + closed(m, keys + (('releaseScope',) if 'releaseScope' in m else ()), 'manifest_schema') require(m['schema'] == 1 and m['purpose'] == 'TEST installed AN dual native', 'manifest_purpose') require(re.fullmatch('[0-9a-f]{40}', str(m['candidateCommit'])) is not None and m['candidateCommit'] == m['buildRevision'], 'candidate_revision_missing') @@ -223,7 +241,7 @@ def pairs(items): require(lock.get('integrity') in (None, sdk['sri']), 'file_tar_integrity_mismatch') else: require(lock.get('integrity') == sdk['sri'], 'registry_integrity_mismatch') - require(isinstance(m['cells'], list) and len(m['cells']) == 11, 'eleven_native_cells_required') + expected_cells = custody_cells(m, (os_name, arch, version)) pins = json.loads((FIXTURES/'host-pins.json').read_text()) require(pins.get('runtimeQualified') is False, 'fixture_pin_cannot_grant_runtime') pinned = {(p['os'],p['arch'],p['version']):p for p in pins['cells']} @@ -233,7 +251,7 @@ def pairs(items): closed(c, ('os', 'arch', 'version', 'hostSourceCommit', 'hostURL', 'archiveName', 'archive', 'archiveSRI', 'executableSHA256', 'candidate', 'nativeApp'), 'cell_schema') ident = (c['os'], c['arch'], c['version']) - require(ident in CELLS and ident not in seen, 'unsupported_or_duplicate_cell') + require(ident in expected_cells and ident not in seen, 'unsupported_or_duplicate_cell') seen.add(ident) pin = pinned[ident] require(c['hostURL']==pin['hostURL'] and c['archive']['sha256']==pin['archiveSHA256'] @@ -246,7 +264,7 @@ def pairs(items): not url.username and not url.password and not url.query and not url.fragment, 'official_archive_url_required') if ident == (os_name, arch, version): selected = c - require(seen == set(CELLS) and selected is not None, 'matrix_incomplete') + require(seen == set(expected_cells) and selected is not None, 'matrix_incomplete') host_archive = checked_file(base, selected['archive']) require(host_archive.name == selected['archiveName'] and sri(host_archive) == selected['archiveSRI'], 'host_archive_identity') candidate = checked_file(base, selected['candidate']) @@ -267,7 +285,11 @@ def fresh_root(artifacts): def network_guard(): if sys.platform == 'linux': - require(os.readlink('/proc/self/ns/net') != os.readlink('/proc/1/ns/net'), 'new_private_netns_required') + host = os.environ.get('AN_HOST_NETNS') + if host is None: + host = os.readlink('/proc/1/ns/net') + require(re.fullmatch(r'net:\[[1-9][0-9]*\]', host) is not None, 'host_network_namespace_invalid') + require(os.readlink('/proc/self/ns/net') != host, 'new_private_netns_required') require({n for _, n in socket.if_nameindex()} == {'lo'}, 'loopback_only_required') diff --git a/scripts/prerelease-package-e2e.py b/scripts/prerelease-package-e2e.py new file mode 100644 index 000000000..0cf300bd7 --- /dev/null +++ b/scripts/prerelease-package-e2e.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Qualify downloaded partial release bytes in new disposable profiles only.""" +import argparse +import functools +import hashlib +import http.server +import importlib.util +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +import tempfile +import threading +import zipfile + +REPO = '777genius/agent-notifications' + + +def run(args, **kwargs): + result = subprocess.run(args, text=True, encoding="utf-8", capture_output=True, timeout=180, **kwargs) + if result.returncode: + raise RuntimeError(f'{args[0]} failed ({result.returncode}): {result.stdout[-3000:]}\n{result.stderr[-3000:]}') + return result.stdout + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def api(path): + return json.loads(run(['gh', 'api', path])) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--source', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True) + args = parser.parse_args() + source, report_path = args.source.resolve(), args.report.resolve() + report_path.parent.mkdir(parents=True, exist_ok=True) + tag, phase, stable, goos, arch, sha = [os.environ['TEST_' + k] for k in + ('TAG', 'PHASE', 'STABLE', 'OS', 'ARCH', 'SOURCE_SHA')] + report = dict(status='FAIL', tag=tag, phase=phase, os=goos, arch=arch, source_sha=sha, + uap_activation='not_tested', desktop_visual_outcome='not_observed') + try: + assert re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag), 'invalid tag' + assert phase in ('draft', 'public') + assert (goos, arch) in (('linux', 'amd64'), ('linux', 'arm64'), ('windows', 'amd64')) + assert run(['git', '-C', str(source), 'rev-parse', 'HEAD']).strip() == sha + tag_ref = api(f'repos/{REPO}/git/ref/tags/{tag}')['object'] + assert tag_ref['type'] == 'commit' and tag_ref['sha'] == sha, 'release tag/source mismatch' + report['tag_commit'] = tag_ref['sha'] + def latest(): + value = api(f'repos/{REPO}/releases/latest') + assert value['tag_name'] == stable and not value['prerelease'] and not value['draft'] + return value['tag_name'] + report['stable_before'] = latest() + pages = json.loads(run(['gh', 'api', '--paginate', '--slurp', f'repos/{REPO}/releases'])) + release = next(r for page in pages for r in page if r['tag_name'] == tag) + assert not release['prerelease'] and release['draft'] == (phase == 'draft') + expected_assets = {'checksums.txt', 'THIRD_PARTY_NOTICES.txt', + 'claude-notifications-windows-amd64-focus.exe'} + for platform, architecture in (('linux', 'amd64'), ('linux', 'arm64'), ('windows', 'amd64')): + suffix = '.exe' if platform == 'windows' else '' + expected_assets.add(f'agent-notify-portable-{platform}-{architecture}.zip') + for tool in ('claude-notifications', 'sound-preview', 'list-devices', 'list-sounds'): + expected_assets.add(f'{tool}-{platform}-{architecture}{suffix}') + actual_assets = [asset['name'] for asset in release['assets']] + assert len(actual_assets) == len(expected_assets) and set(actual_assets) == expected_assets, 'unexpected or missing partial-release asset' + report['release_id'] = release['id'] + with tempfile.TemporaryDirectory(prefix='TEST-prerelease-package-') as scratch: + root = Path(scratch).resolve() + spec = importlib.util.spec_from_file_location('artifact_e2e', source / 'scripts/release-artifact-e2e.py') + qualifier = importlib.util.module_from_spec(spec) + spec.loader.exec_module(qualifier) + qualifier._windows_private_scratch_root(root) + assets = root / 'assets' + assets.mkdir() + binary_name = f'claude-notifications-{goos}-{arch}' + ('.exe' if goos == 'windows' else '') + zip_name = f'agent-notify-portable-{goos}-{arch}.zip' + command = ['gh', 'release', 'download', tag, '--repo', REPO, '--dir', str(assets)] + companions = [binary_name.replace('claude-notifications', prefix, 1) + for prefix in ('sound-preview', 'list-devices', 'list-sounds')] + download_names = [binary_name, zip_name, 'checksums.txt', *companions] + if goos == 'windows': + download_names.append(binary_name.removesuffix('.exe') + '-focus.exe') + for name in download_names: + command += ['--pattern', name] + run(command) + entries = {} + for line in (assets / 'checksums.txt').read_text(encoding='utf-8').splitlines(): + if not line.strip() or line.startswith('#'): + continue + match = re.fullmatch(r'([0-9a-fA-F]{64})\s+\*?([^\r\n]+)', line) + assert match, 'invalid checksum record' + value, name = match.groups() + assert name not in entries, 'duplicate checksum record' + entries[name] = value.lower() + report['asset_sha256'] = {} + for name in download_names: + if name == 'checksums.txt': + continue + actual = digest(assets / name) + assert actual == entries[name], f'checksum mismatch: {name}' + report['asset_sha256'][name] = actual + package = root / 'package' + package.mkdir() + with zipfile.ZipFile(assets / zip_name) as bundle: + names = set() + for info in bundle.infolist(): + path = PurePosixPath(info.filename) + assert not path.is_absolute() and '..' not in path.parts and '\\' not in info.filename + assert not re.match(r'^[A-Za-z]:', info.filename) + assert info.filename not in names and (info.external_attr >> 16) & 0o170000 != 0o120000 + assert info.file_size <= 80 << 20 + names.add(info.filename) + assert sum(i.file_size for i in bundle.infolist()) <= 160 << 20 + bundle.extractall(package) + manifest = json.loads((package / 'plugin.json').read_text(encoding='utf-8')) + assert manifest['version'] == tag.removeprefix('v') + zipped = package / 'bin' / ('claude-notifications.exe' if goos == 'windows' else 'claude-notifications') + zipped.chmod(0o755) + assert digest(zipped) == report['asset_sha256'][binary_name] + env = {k: os.environ[k] for k in ('PATH', 'SystemRoot', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT') if k in os.environ} + home = root / 'HOME' + home.mkdir() + env.update(HOME=str(home), USERPROFILE=str(home), GIT_CONFIG_NOSYSTEM='1') + for key in ('CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'APPDATA', 'LOCALAPPDATA', 'XDG_CONFIG_HOME', + 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_RUNTIME_DIR', 'TMPDIR', 'TMP', 'TEMP'): + folder = home / key + folder.mkdir(mode=0o777 if os.name == 'nt' else 0o700) + env[key] = str(folder) + assert run([str(zipped), 'version'], env=env, cwd=home).strip() == 'claude-notifications ' + tag + relay = root / 'relay' + relay.mkdir() + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(relay)) + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), handler) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + # Draft assets have no public URLs; serve the downloaded, verified bytes. + staged = relay / 'download' / tag + staged.mkdir(parents=True) + for item in assets.iterdir(): + (staged / item.name).write_bytes(item.read_bytes()) + base = (f'http://127.0.0.1:{server.server_port}/download/{tag}' if phase == 'draft' + else f'https://github.com/{REPO}/releases/download/{tag}') + consumer = root / 'TEST-consumer' + target = consumer / 'bin' + target.mkdir(parents=True) + for relative in ('bin/hook-wrapper.sh', 'bin/codex-hook-wrapper.sh', + 'bin/install.sh', '.claude-plugin/plugin.json'): + copied = consumer / relative + copied.parent.mkdir(parents=True, exist_ok=True) + copied.write_bytes((source / relative).read_bytes()) + copied.chmod(0o755 if relative.endswith('.sh') else 0o600) + env.update(PLUGIN_ROOT=str(consumer), CLAUDE_PLUGIN_ROOT=str(consumer)) + env.update(INSTALL_TARGET_DIR=str(target), RELEASE_URL=base, CHECKSUMS_URL=base + '/checksums.txt') + for attempt in ('fresh', 'repeat'): + output = run([os.environ.get('TEST_BASH', 'bash'), (consumer / 'bin/install.sh').as_posix()], env=env, cwd=home) + (report_path.parent / f'install-{attempt}.log').write_text(output, encoding="utf-8") + installed = target / binary_name + assert installed.is_file(), 'installed binary missing' + assert digest(installed) == report['asset_sha256'][binary_name] + for name in companions + ([binary_name.removesuffix('.exe') + '-focus.exe'] if goos == 'windows' else []): + assert (target / name).is_file(), f'installed companion missing: {name}' + assert digest(target / name) == report['asset_sha256'][name], f'installed companion mismatch: {name}' + assert run([str(installed), 'version'], env=env, cwd=home).strip() == 'claude-notifications ' + tag + report['installer_' + attempt] = 'PASS' + report['installed_runtime_version'] = 'claude-notifications ' + tag + # Red if a real hook silently drops/misroutes a payload or replaces + # the opted-in candidate with the latest stable runtime. + hook_env = {k: v for k, v in env.items() if k not in + ('RELEASE_URL', 'CHECKSUMS_URL', 'INSTALL_TARGET_DIR')} + bash = os.environ.get('TEST_BASH', 'bash') + if goos == 'windows': + assert Path(bash).as_posix() == 'C:/Program Files/Git/bin/bash.exe' + config = Path(run([str(installed), 'config', 'path'], env=hook_env, cwd=home).strip()) + run([str(installed), 'config', 'init'], env=hook_env, cwd=home) + deliveries = [] + + class Sink(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_POST(self): + body = self.rfile.read(int(self.headers['Content-Length'])).decode('utf-8') + deliveries.append((self.path, json.loads(body))) + self.send_response(200) + self.end_headers() + self.wfile.write(b'ok') + + sink = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Sink) + sink_worker = threading.Thread(target=sink.serve_forever, daemon=True) + sink_worker.start() + try: + config.write_text(json.dumps({ + 'schemaVersion': 2, + 'notifications': { + 'desktop': {'enabled': False, 'sound': False, 'terminalBell': False}, + 'webhook': {'enabled': True, 'preset': 'slack', + 'url': f'http://127.0.0.1:{sink.server_port}/unexpected-shared'}, + }, + 'agents': {product: {'notifications': {'webhook': { + 'url': f'http://127.0.0.1:{sink.server_port}/{product}'}}} + for product in ('claude', 'codex')}, + 'future': {'preserved': 9007199254740993}, + }), encoding='utf-8') + before = config.read_bytes() + run([str(installed), 'config', 'init'], env=hook_env, cwd=home) + assert config.read_bytes() == before + for attempt in ('fresh', 'repeat'): + for product in ('claude', 'codex'): + cwd = root / f'TEST-hook-{attempt}-{product}' + cwd.mkdir() + marker = f'package-{phase}-{goos}-{arch}-{attempt}-{product}' + event = 'Stop' if product == 'codex' else 'Notification' + payload = dict(hook_event_name=event, session_id=marker, cwd=str(cwd)) + if product == 'codex': + payload.update(turn_id=marker, last_assistant_message=marker, + stop_hook_active=False) + else: + payload.update(notification_type='permission_prompt', message=marker) + wrapper = target / ('codex-hook-wrapper.sh' if product == 'codex' + else 'hook-wrapper.sh') + command = [bash, wrapper.as_posix(), 'handle-hook', event] + if product == 'codex': + command += ['--product', 'codex'] + count = len(deliveries) + output = run(command, input=json.dumps(payload), env=hook_env, cwd=cwd) + assert len(deliveries) == count + 1, (marker, output, deliveries) + path, body = deliveries[-1] + attachment = body['attachments'][0] + assert path == '/' + product, (marker, path, body) + # NotificationPayload intentionally projects Question + session, + # whereas Codex Stop projects last_assistant_message. + if product == 'claude': + assert attachment['footer'] == f'Session: {marker} | Claude', body + assert 'Question' in attachment['text'], body + else: + assert marker in attachment['text'], body + assert sum(marker in json.dumps(item) for _, item in deliveries) == 1 + assert config.read_bytes() == before, 'hook rewrote custom config' + assert digest(installed) == report['asset_sha256'][binary_name], 'hook changed runtime' + version = run([str(installed), 'version'], env=hook_env, cwd=cwd).strip() + report['installed_runtime_version'] = version + assert version == 'claude-notifications ' + tag, version + selected_version = run([bash, wrapper.as_posix(), 'version'], + env=hook_env, cwd=cwd).strip() + assert selected_version == 'claude-notifications ' + tag, selected_version + report['hook_chain_' + attempt] = 'PASS' + assert len(deliveries) == 4 + report['hook_chain'] = dict(result='PASS', webhook_deliveries=4, + installed_runtime_version=version) + finally: + sink.shutdown() + sink.server_close() + sink_worker.join() + output = run([sys.executable, str(source / 'scripts/release-artifact-e2e.py'), + '--binary', str(installed), '--version', tag], env=env, cwd=home) + (report_path.parent / 'webhook.log').write_text(output, encoding="utf-8") + report['webhook'] = json.loads(output.strip().splitlines()[-1]) + assert report['webhook']['result'] == 'PASS' and report['webhook']['webhook_deliveries'] == 9 + finally: + server.shutdown() + server.server_close() + worker.join() + report['stable_after'] = latest() + report['status'] = 'PASS' + except Exception as error: + report['error'] = str(error) + raise + finally: + report_path.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + + +if __name__ == '__main__': + main() diff --git a/scripts/release-opencode-business-proof.py b/scripts/release-opencode-business-proof.py new file mode 100644 index 000000000..ca2823f8a --- /dev/null +++ b/scripts/release-opencode-business-proof.py @@ -0,0 +1,555 @@ +"""External TEST evidence sealer. No native execution, network, builds or grants. + +The immutable installed business runner checks only custody. This tool checks +the contents of fresh primitive reports before producing its six-key input. +""" +import argparse +import copy +import hashlib +import json +import math +from pathlib import Path, PurePosixPath +import re +import tarfile +import types +import unittest + +CANDIDATE = 'e86149a724f9b96cb5ee193d63dbc5a533c61e7e' +CLOCK_VALIDATOR_SHA = '1c30c5e625ef4b59435a4231ef9bc45882ceacb6dcb16fa0419ccffada6c72e0' +ORIGINAL_RUN = '37519336110' +ORIGINAL_ARCHIVE = 'ea156e1978d5acad56ff6cfbebc8cb20ba646b55a271e137dc696e6646bc64d9' +CELLS = {(os_name, arch, version) for os_name, arch in + [('linux', 'amd64'), ('linux', 'arm64'), ('windows', 'amd64')] + for version in ['1.18.33', '2.0.21']} | {('linux', 'amd64', '1.18.34')} +READER_FLAGS = ('productionQualified', 'installedQualified', 'timePolicyQualified', + 'sourceEpochQualified', 'finalSpanQualified', 'platformLifetimeQualified', + 'loadedMappedBytesQualified', 'fullNativeQualified', 'clockQualified', + 'visibleDesktopQualified', 'sourceDescriptorGranted') +LIMIT = 32 * 1024 * 1024 + + +def need(value, reason): + if not value: + raise ValueError(reason) + + +def digest(body): + return hashlib.sha256(body).hexdigest() + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode() + + +def unique(pairs): + result = {} + for key, value in pairs: + need(key not in result, 'duplicate_json_key') + result[key] = value + return result + + +def regular(path): + path = Path(path).absolute() + need(not any(p.is_symlink() for p in (path, *path.parents)), 'input_symlink') + need(path.is_file(), 'input_regular_file') + return path + + +def read(path, expected): + path = regular(path) + need(re.fullmatch('[a-f0-9]{64}', str(expected)), 'required_input_digest') + need(path.stat().st_size <= LIMIT, 'input_size_bound') + body = path.read_bytes() + need(digest(body) == expected, 'input_digest_mismatch') + value = json.loads(body, object_pairs_hook=unique, + parse_constant=lambda _: (_ for _ in ()).throw(ValueError('nonfinite_json'))) + need(isinstance(value, dict), 'report_object_required') + return value, body + + +def leaf(root, name, expected): + relative = PurePosixPath(name) + need(not relative.is_absolute() and relative.parts and + all(p not in ('.', '..') for p in relative.parts) and '\\' not in name, + 'source_leaf_path') + path = regular(root.joinpath(*relative.parts)) + need(path.is_relative_to(root.absolute()), 'source_leaf_escape') + need(digest(path.read_bytes()) == expected, 'source_leaf_changed') + + +def refuse_grants(value): + """Reject nested affirmative grants, including future report grant fields.""" + if isinstance(value, dict): + for key, item in value.items(): + if key.lower().endswith(('qualified', 'granted')): + need(item is False, 'qualification_grant_refused') + refuse_grants(item) + elif isinstance(value, list): + for item in value: + refuse_grants(item) + + +def network_boundary(boundary, os_name): + need(isinstance(boundary, dict), 'native_network_boundary_required') + if os_name == 'linux': + need(re.fullmatch(r'net:\[[1-9][0-9]*\]', str(boundary.get('host'))) and + re.fullmatch(r'net:\[[1-9][0-9]*\]', str(boundary.get('private'))) and + boundary['host'] != boundary['private'] and boundary.get('interfaces') == ['lo'], + 'native_private_loopback_namespace') + else: + need(boundary.get('platform') == 'windows' and boundary.get('networkIsolationClaimed') is False and + boundary.get('isolation') == 'private_files_minimal_env_loopback_provider_no_network_isolation_claim', + 'honest_windows_network_limitation') + + +def identity(report, expected, cell): + need(report.get('schema') == 1 and report.get('candidateCommit') == CANDIDATE, + 'exact_candidate_required') + need(report.get('cell') == cell, 'exact_cell_required') + for key in ('embeddedSHA256', 'candidateSHA256', 'manifestSHA256'): + need(report.get(key) == expected[key], 'exact_' + key) + need(not report.get('diagnosticOnly') and not report.get('failureReason'), + 'passing_nondiagnostic_report_required') + refuse_grants(report) + + +def validator(source): + path = regular(source / 'scripts/opencode-platform-clock-prequalification.py') + body = path.read_bytes() + need(digest(body) == CLOCK_VALIDATOR_SHA, 'immutable_clock_validator') + # Execute verified source directly: never consume or create a pyc cache. + module = types.ModuleType('immutable_clock_semantics') + module.__file__ = str(path) + exec(compile(body, str(path), 'exec'), module.__dict__) + return module + + +def selected_manifest(manifest, manifest_sha, cell): + need(manifest.get('candidateCommit') == manifest.get('buildRevision') == CANDIDATE, + 'manifest_exact_candidate') + need(manifest.get('releaseScope') == 'linux-windows', 'explicit_partial_scope') + cells = manifest.get('cells', []) + actual = [(x['os'], x['arch'], x['version']) for x in cells] + need(len(actual) == len(CELLS) and set(actual) == CELLS, 'all_seven_cells_required') + matches = [x for x in cells if {k: x[k] for k in cell} == cell] + need(len(matches) == 1, 'selected_cell_required') + item = matches[0] + expected = {'embeddedSHA256': manifest['assets']['embedded']['sha256'], + 'candidateSHA256': item['candidate']['sha256'], 'manifestSHA256': manifest_sha, + 'sdkArchiveSHA256': manifest['sdk']['archive']['sha256'], + 'hostArchiveSHA256': item['archive']['sha256'], + 'ownedImageSHA256': item['executableSHA256'], 'hostSourceCommit': item['hostSourceCommit']} + need(all(re.fullmatch('[a-f0-9]{64}', str(v)) for k, v in expected.items() if k != 'hostSourceCommit') and + re.fullmatch('[a-f0-9]{40}', str(expected['hostSourceCommit'])), + 'closed_manifest_digests') + return expected + + +def source_leaves(leaves, source, required): + need(isinstance(leaves, list) and leaves, 'source_closure_required') + paths = [] + for record in leaves: + need(isinstance(record, dict), 'source_leaf_record') + if 'path' in record: + paths.append(record['path']) + leaf(source, record['path'], record['actualSHA256']) + need(len(paths) == len(set(paths)) and set(required) <= set(paths), 'complete_source_closure') + + +def validate_clock(report, expected, cell, source, module): + need(report.get('schema') == 1 and + report.get('purpose') == 'fresh_release_artifact_platform_clock_observation' and + report.get('status') == 'module_prequalification_observed' and + report.get('qualificationLevel') == 'module_prequalification_only', 'actual_clock_report_required') + need(report.get('expectedSourceCommit') == report.get('actualGitHEAD') == CANDIDATE, + 'exact_clock_candidate') + need(report.get('platform') == cell['os'] and report.get('arch') == cell['arch'] and + report.get('plannedVersions') == [cell['version']], 'exact_clock_cell') + need(report.get('originalManifestSHA256') == expected['manifestSHA256'], 'clock_manifest_binding') + if cell['os'] == 'linux': + network_boundary(report.get('nativeNamespaceProof'), cell['os']) + need(report.get('selectedCell') == cell and report.get('candidateSHA256') == expected['candidateSHA256'] and + report.get('embeddedSHA256') == expected['embeddedSHA256'] and + report.get('manifestSHA256') == expected['manifestSHA256'], 'clock_selected_identity') + need(report.get('immutableHarnessGitBlobSHA256') == report.get('immutableHarnessActualSHA256') == + CLOCK_VALIDATOR_SHA, 'actual_immutable_clock_harness') + need(report.get('budgets') == module.BUDGETS and report.get('artifactReused') is True and + report.get('buildPerformed') is False and report.get('cleanTrackedCheckout') is True and + not report.get('failureReason'), 'honest_clock_reuse') + refuse_grants(report) + need(all(report.get(k) is False for k in module.QUALIFICATIONS), 'clock_false_grants') + need(all(report.get(k) == 0 for k in ('modelCalls', 'providerCalls', 'sessionCreates', + 'installerCalls', 'suspendExperiments', 'qualificationRowsFilled')), 'clock_zero_business_effects') + leaves = report.get('checkoutLeaves') + need(report.get('checkoutClosureSHA256') == digest(module.canonical(leaves)), 'clock_checkout_closure_hash') + source_leaves(leaves, source, ['opencode-plugin/platform-clock.mjs', 'opencode-plugin/protocol.mjs']) + build = report.get('goBuildClosure') + need(isinstance(build, dict) and report.get('goBuildClosureSHA256') == digest(module.canonical(build)), + 'clock_go_closure_hash') + need(build.get('sourceCommit') == CANDIDATE and build.get('binarySHA256') == expected['candidateSHA256'] and + build.get('artifactReused') is True and build.get('buildPerformed') is False and + build.get('vcsModified') is False and build.get('CGO_ENABLED') == '1', 'clock_original_binary') + settings = build.get('originalBuildSettings', {}) + need(settings.get('vcs.revision') == CANDIDATE and settings.get('vcs.modified') == 'false' and + settings.get('GOOS') == cell['os'] and settings.get('GOARCH') == cell['arch'] and + settings.get('CGO_ENABLED') == '1', 'clock_original_build_identity') + need(build.get('originalRunID') == report.get('originalRunID') == ORIGINAL_RUN and + build.get('originalParentArchiveSHA256') == report.get('originalParentArchiveSHA256') == ORIGINAL_ARCHIVE, + 'clock_original_run_binding') + need(build.get('closureMethod') == 'fresh_go_list_deps_and_go_mod_verify_bound_to_original_binary_modules' and + isinstance(build.get('modules'), dict) and build['modules'], 'clock_dependency_closure') + source_leaves(build.get('leaves'), source, ['cmd/claude-notifications/main.go', + 'cmd/claude-notifications/opencode_clock.go', 'internal/opencodeevent/clockprotocol.go']) + cases = report.get('cases') + need(isinstance(cases, list) and len(cases) == 1, 'single_actual_clock_case') + metadata = report.get('caseMetadata', {}) + need(metadata.get('version') == cell['version'] and metadata.get('sourceCommit') == CANDIDATE and + metadata.get('originCommit') == expected['hostSourceCommit'] and + metadata.get('os') == cell['os'] and metadata.get('goarch') == cell['arch'] and + metadata.get('platform') == ('win32' if cell['os'] == 'windows' else cell['os']) and + metadata.get('arch') == ('x64' if cell['arch'] == 'amd64' else 'arm64') and + metadata.get('imageSha256') == expected['ownedImageSHA256'] and + metadata.get('archiveSha256') == expected['hostArchiveSHA256'] and + metadata.get('helperSha256') == expected['candidateSHA256'], 'clock_actual_stock_case_binding') + need(metadata.get('moduleLeaves') == [x for x in leaves if x['path'] in module.MODULES] and + {x['path'] for x in metadata['moduleLeaves']} == set(module.MODULES), 'clock_actual_module_leaf_binding') + case = cases[0] + keys = {'status', 'actualModuleBound', 'rounds', 'samples', 'comparisons', 'datePredicates', + 'disposeCalls', 'sampleAfterDisposeRefused', 'operationElapsedMs', 'jsElapsedMs', 'checks', + *module.QUALIFICATIONS} + if cell['os'] == 'windows': + keys.update(('warmupSamples', 'measuredSamples', 'measuredInstances', 'ownedClockLifetime')) + actual = case.get('actualModuleResult') + need(isinstance(actual, dict), 'genuine_clock_disposed_result_required') + module.validate_result(actual, cell['os']) + need(all(case[k] == actual[k] for k in keys), 'clock_summary_matches_actual_result') + need(re.fullmatch('[a-f0-9]{64}', str(case.get('originalModuleStreamSHA256'))), + 'genuine_clock_stream_custody') + need(case.get('kernelExecutingImageVerified') is True and case.get('allOwnedHandlesClosed') is True and + case.get('helperStarts') == case.get('helperActualCloses') == 3 and + case.get('ownedStarts') == case.get('ownedActualCloses') == 4 and + case.get('maxOwnedWeight') <= 2 and not case.get('failureReason'), 'clock_case_actual_closure') + need(len(case.get('actualHelperOutputReceiptSHA256', [])) == 3 and + all(re.fullmatch('[a-f0-9]{64}', str(x)) for x in case['actualHelperOutputReceiptSHA256']) and + re.fullmatch('[a-f0-9]{64}', str(case.get('rawPrivateReceiptSHA256'))), 'clock_raw_receipt_custody') + parent = report.get('parentOwnership', {}) + need(parent.get('allOwnedHandlesClosed') is True and + parent.get('ownedStarts') == parent.get('ownedActualCloses') and + parent.get('helperStarts') == parent.get('helperActualCloses') and + report.get('helperStarts') == report.get('helperActualCloses') == 3 and + report.get('allOwnedHandlesClosed') is True and report.get('maxOwnedWeightIncludingSupervisor') <= 4, + 'clock_parent_actual_closure') + + +def validate_reader_graph(report, source, expected): + graph = report.get('sourceGraph', {}) + need(graph.get('schema') == 1 and graph.get('status') == 'fresh_release_reader_bundle_built' and + graph.get('candidateCommit') == CANDIDATE and graph.get('sdkVersion') == '0.3.0' and + graph.get('sdkArchiveSHA256') == expected['sdkArchiveSHA256'] and + graph.get('bundleSHA256') == report.get('bundleSHA256'), 'fresh_reader_source_graph') + compiler = graph.get('compiler', {}) + need(compiler.get('strict') is True and compiler.get('erasableSyntaxOnly') is True, + 'reader_strict_compilation') + lock = json.loads(regular(source / 'opencode-plugin/package-lock.json').read_bytes(), object_pairs_hook=unique) + need(compiler.get('esbuildVersion') == lock['packages']['node_modules/esbuild']['version'] and + re.fullmatch('[a-f0-9]{64}', str(graph.get('metafileSHA256'))) and + re.fullmatch('[a-f0-9]{64}', str(report.get('bundleSHA256'))), 'reader_locked_compiler_and_bundle') + records = graph.get('leaves') + need(isinstance(records, list) and records, 'reader_source_leaves_required') + known = {x['path']: x for x in records} + need(len(known) == len(records), 'reader_unique_leaves') + compiled = graph.get('compiledInputs') + required = {'candidate/opencode-plugin/native-v1.mjs', 'candidate/opencode-plugin/native-v2.mjs', + 'candidate/opencode-plugin/protocol.mjs', + 'node_modules/universal-agent-plugins-opencode-events/v1.js', + 'node_modules/universal-agent-plugins-opencode-events/v2.js'} + need(isinstance(compiled, list) and len(compiled) == len(set(compiled)) and + required <= set(compiled) <= set(known), 'reader_complete_import_graph') + sdk = regular(source / 'opencode-plugin/vendor/universal-agent-plugins-opencode-events-0.3.0.tgz') + need(digest(sdk.read_bytes()) == expected['sdkArchiveSHA256'], 'reader_exact_sdk_archive') + with tarfile.open(sdk, 'r:gz') as packed: + sdk_members = {x.name: packed.extractfile(x).read() for x in packed.getmembers() if x.isfile()} + sdk_prefix = 'node_modules/universal-agent-plugins-opencode-events/' + need({sdk_prefix + member[len('package/'):] for member in sdk_members} <= set(known), + 'reader_complete_sdk_source_archive') + for name, record in known.items(): + if name.startswith('candidate/'): + need(record['origin'] == 'candidate:' + CANDIDATE, 'reader_original_leaf_origin') + leaf(source, name[len('candidate/'):], record['sha256']) + body = regular(source / name[len('candidate/'):]).read_bytes() + elif name == 'sdk.tgz': + need(record['origin'] == 'candidate:' + CANDIDATE, 'reader_sdk_archive_origin') + body = sdk.read_bytes() + elif name.startswith('node_modules/universal-agent-plugins-opencode-events/'): + member = 'package/' + name.split('universal-agent-plugins-opencode-events/', 1)[1] + need(record['origin'] == 'sdk:' + expected['sdkArchiveSHA256'] + '#' + member, + 'reader_original_sdk_leaf_origin') + need(member in sdk_members, 'reader_original_sdk_member') + body = sdk_members[member] + else: + mapping = {'release-opencode-reader.mts': ('fresh-reader-consumer', 'release-opencode-reader.mts'), + 'driver/release-opencode-reader.py': ('fresh-reader-driver', 'release-opencode-reader.py')} + need(name in mapping and record['origin'] == mapping[name][0], 'reader_reviewed_tool_leaf') + body = regular(Path(__file__).parent / mapping[name][1]).read_bytes() + need(record['sha256'] == digest(body) and record['bytes'] == len(body), 'reader_source_leaf_identity') + + +def validate_reader(report, expected, cell, source): + identity(report, expected, cell) + network_boundary(report.get('nativeNetworkBoundary'), cell['os']) + need(report.get('purpose') == 'TEST fresh stock OpenCode packaged reader primitive' and + report.get('status') == 'release_packaged_reader_observed' and + all(report.get(k) is False for k in READER_FLAGS), 'actual_bounded_reader_report') + for key in ('sdkArchiveSHA256', 'hostArchiveSHA256', 'ownedImageSHA256'): + need(report.get(key) == expected[key], 'reader_exact_' + key) + need(report.get('hostSourceCommit') == expected['hostSourceCommit'], 'reader_stock_origin_binding') + need(report.get('installedRuntimeVersion') == cell['version'] and + report.get('freshTestRootCreated') is True and report.get('packagedReaderTransportObserved') is True and + report.get('sourcesArchiveBundleUnchanged') is True and report.get('providerThreadJoined') is True and + report.get('ownedLeaderWaitObserved') is True and not report.get('hostForcedKillUsed'), + 'reader_actual_scope_and_close') + validate_reader_graph(report, source, expected) + v2 = cell['version'] == '2.0.21' + validate_provider(report, v2) + ready, closed = report.get('readerReady', {}), report.get('readerClosed', {}) + for receipt in (ready, closed): + need(receipt.get('schemaVersion') == 1 and receipt.get('scope') == 'TEST packaged SDK reader' and + receipt.get('generation') == ('v2' if v2 else 'v1') and receipt.get('version') == cell['version'] and + receipt.get('os') == cell['os'] and receipt.get('arch') == cell['arch'] and + receipt.get('ownedImageSHA256') == expected['ownedImageSHA256'] and + receipt.get('testAuthorityDerived') is True, 'reader_native_receipt_identity') + need(all(receipt.get(k) is False for k in READER_FLAGS if k != 'sourceDescriptorGranted'), + 'reader_receipt_false_grants') + need(ready.get('nativePID') == closed.get('nativePID') and type(ready.get('nativePID')) is int and + ready['nativePID'] > 1 and ready.get('parentPID') == closed.get('parentPID') and + ready.get('actualExecPath') == closed.get('actualExecPath') and closed.get('actualSDKDispose') is True, + 'reader_actual_dispose_identity') + rows = report.get('observations') + need(isinstance(rows, list) and 0 < len(rows) <= 512 and + all(isinstance(r, dict) and isinstance(r.get('value'), dict) and + r.get('kind') != 'uncertainty' for r in rows), 'reader_certain_actual_trace') + need(any(r['kind'] == 'reader-closed' and r['value'].get('actualDrain') is True for r in rows), + 'reader_trace_actual_drain') + if v2: + validate_v2(rows, ready, closed, report) + else: + validate_v1(rows, closed, report) + + +def validate_provider(report, v2): + calls = 0 if v2 else 1 + need(all(type(report.get(k)) is int for k in ('plannedProviderTransactions', + 'actualProviderHTTPRequestAttempts', 'actualProviderTransactions', 'actualToolCalls')), + 'reader_actual_numeric_counts') + need(report.get('mode') == ('api-only' if v2 else 'one-completion') and + report.get('plannedProviderTransactions') == report.get('actualProviderHTTPRequestAttempts') == + report.get('actualProviderTransactions') == calls and report.get('actualToolCalls') == 0 and + len(report.get('providerReceipts', [])) == calls, 'reader_exact_provider_budget') + + +def validate_v2(rows, ready, closed, report): + need(ready.get('firstReaderClosureObserved') is True and ready.get('actualAbortRequested') is True and + closed.get('actualSDKDone') is True and closed.get('actualAbortRequested') is True and + closed.get('sdkFacts') == 0 and all(closed.get(k) == 2 for k in + ('subscriptions', 'actualNativeReaderClosures', 'registrations', 'actualRegistrationDisposals', 'markerReads')), + 'reader_two_actual_rpc_closures') + need(report.get('factoryFinalizationObserved') is False, 'v2_no_business_completion_claim') + closes = [(i, r['value']) for i, r in enumerate(rows) if r['kind'] == 'native-reader-close'] + aborts = [(i, r['value']) for i, r in enumerate(rows) if r['kind'] == 'requested-native-abort'] + need(len(closes) == 2 and [r['number'] for _, r in closes] == [1, 2] and + closes[0][1].get('actualSignalAborted') is True and len(aborts) == 1 and + aborts[0][1].get('number') == 1 and aborts[0][0] < closes[0][0], 'reader_abort_before_close') + registers = [r['value'] for r in rows if r['kind'] == 'rpc-register'] + markers = [r['value'] for r in rows if r['kind'] == 'rpc-marker-consumed'] + native = [r['value'] for r in rows if r['kind'] == 'native-v2'] + need(len(registers) == len(markers) == 2 and [r.get('number') for r in registers] == [1, 2] and + [r.get('number') for r in markers] == [1, 2] and + not any(r['kind'] == 'sdk-fact' for r in rows), 'reader_two_actual_registrations') + for index, (registration, marker) in enumerate(zip(registers, markers), 1): + envelope = marker.get('envelope', {}) + need(marker.get('markerReads') == index and envelope.get('type') == registration.get('type') and + isinstance(envelope.get('id'), str) and re.fullmatch('h:[a-f0-9]{24}', envelope['id']) and + type(envelope.get('created')) in (int, float) and math.isfinite(envelope['created']) and + 0 < envelope['created'] < 1e16 and + isinstance(envelope.get('data'), dict) and set(envelope['data']) == {'nonce'} and + isinstance(envelope['data']['nonce'], str) and + re.fullmatch('h:[a-f0-9]{24}', envelope['data']['nonce']) and envelope in native, + 'reader_actual_rpc_identity_time') + callback_at = next(i for i, row in enumerate(rows) if row['kind'] == 'native-v2' and row['value'] == envelope) + marker_at = next(i for i, row in enumerate(rows) if row['kind'] == 'rpc-marker-consumed' and row['value'] == marker) + need(callback_at < marker_at, 'reader_native_callback_before_marker') + need(markers[0]['envelope']['id'] != markers[1]['envelope']['id'] and + markers[0]['envelope']['data']['nonce'] != markers[1]['envelope']['data']['nonce'], + 'reader_distinct_rpc_markers') + disposals = [r['value'].get('number') for r in rows if r['kind'] == 'rpc-disposed'] + need(disposals == [1, 2], 'reader_actual_registration_disposals') + + +def validate_v1(rows, closed, report): + need(closed.get('actualTrackedJobsSettled') is True and type(closed.get('nativeCalls')) is int and + closed['nativeCalls'] > 0 and sum(r['kind'] == 'sdk-observe-invoked' for r in rows) == closed['nativeCalls'] and + closed.get('sdkFacts') == 1 and report.get('factoryFinalizationObserved') is True, + 'reader_actual_callback_jobs_settled') + identity = report.get('nativeV1Identity', {}) + need(identity.get('finish') == 'stop' and all(isinstance(identity.get(k), str) and + re.fullmatch('h:[a-f0-9]{24}', identity[k]) for k in ('sessionID', 'userID', 'assistantID')) and + len({identity[k] for k in ('sessionID', 'userID', 'assistantID')}) == 3 and + all(type(identity.get(k)) is int and identity[k] > 0 for k in + ('userCreated', 'assistantCreated', 'assistantCompleted')) and + identity['userCreated'] <= identity['assistantCreated'] <= identity['assistantCompleted'], + 'reader_actual_native_turn_identity_time') + facts = [r['value'] for r in rows if r['kind'] == 'sdk-fact'] + need(len(facts) == 1, 'reader_one_actual_sdk_fact') + value, fact = facts[0], facts[0].get('event', {}) + provenance = fact.get('provenance', {}) + need(value.get('currentAtCallback') is True and value.get('clockID') == 'local-performance' and + fact.get('rootSession') is True and fact.get('kind') == 'turn_idle_verified' and + fact.get('sessionID') == identity['sessionID'] and fact.get('turnID') == identity['userID'] and + fact.get('messageID') == identity['assistantID'] and provenance.get('generation') == 'v1' and + provenance.get('timeBasis') == 'assistant_completed' and + provenance.get('nativeTime') == identity['assistantCompleted'], 'reader_original_callback_finalization') + native = [r['value'].get('properties', {}).get('info', {}) for r in rows if + r['kind'] == 'native-v1' and r['value'].get('type') == 'message.updated'] + need(any(info.get('id') == identity['assistantID'] and + info.get('time', {}).get('completed') == identity['assistantCompleted'] for info in native), + 'reader_independent_native_final_callback') + need(all(r.get('requestKind') == 'ordinary' for r in report['providerReceipts']), 'reader_ordinary_provider_request') + + +class InertContracts(unittest.TestCase): + """Counterfeit reports must fail without launching a host or provider.""" + def test_identity_and_grants(self): + expected = {k: 'a' * 64 for k in ('embeddedSHA256', 'candidateSHA256', 'manifestSHA256')} + cell = {'os': 'linux', 'arch': 'amd64', 'version': '2.0.21'} + report = dict(schema=1, candidateCommit=CANDIDATE, cell=cell, **expected) + identity(report, expected, cell) + for mutation in ({'candidateCommit': '0' * 40}, {'cell': dict(cell, arch='arm64')}, + {'clockQualified': True}, {'nested': {'productionQualified': True}}, + {'candidateSHA256': 'b' * 64}): + with self.subTest(mutation=mutation), self.assertRaises(ValueError): + identity(dict(report, **mutation), expected, cell) + + def test_seven_cell_manifest(self): + cell = {'os': 'linux', 'arch': 'amd64', 'version': '2.0.21'} + manifest = {'candidateCommit': CANDIDATE, 'buildRevision': CANDIDATE, + 'releaseScope': 'linux-windows', 'cells': []} + # Scope markers alone cannot excuse any missing mandatory cell. + with self.assertRaisesRegex(ValueError, 'all_seven_cells_required'): + selected_manifest(manifest, 'a' * 64, cell) + + def test_provider_counts_are_actual_not_planned(self): + report = dict(mode='api-only', plannedProviderTransactions=0, actualProviderHTTPRequestAttempts=0, + actualProviderTransactions=0, actualToolCalls=0, providerReceipts=[]) + validate_provider(report, True) + for key in ('actualProviderHTTPRequestAttempts', 'actualProviderTransactions', 'actualToolCalls'): + with self.subTest(key=key), self.assertRaises(ValueError): + validate_provider(dict(report, **{key: 1}), True) + + def test_v1_original_time_and_independent_callback(self): + native = dict(sessionID='h:' + '1' * 24, userID='h:' + '2' * 24, assistantID='h:' + '3' * 24, + userCreated=100, assistantCreated=101, assistantCompleted=102, finish='stop') + fact = dict(rootSession=True, kind='turn_idle_verified', sessionID=native['sessionID'], + turnID=native['userID'], messageID=native['assistantID'], + provenance={'generation': 'v1', 'timeBasis': 'assistant_completed', 'nativeTime': 102}) + rows = [{'kind': 'sdk-observe-invoked', 'value': {}}, + {'kind': 'sdk-fact', 'value': {'event': fact, 'currentAtCallback': True, 'clockID': 'local-performance'}}, + {'kind': 'native-v1', 'value': {'type': 'message.updated', 'properties': + {'info': {'id': native['assistantID'], 'time': {'completed': 102}}}}}] + closed = dict(actualTrackedJobsSettled=True, nativeCalls=1, sdkFacts=1) + report = dict(nativeV1Identity=native, factoryFinalizationObserved=True, + providerReceipts=[{'requestKind': 'ordinary'}]) + validate_v1(rows, closed, report) + damaged = copy.deepcopy(rows) + damaged[1]['value']['event']['provenance']['nativeTime'] = 103 + with self.assertRaisesRegex(ValueError, 'reader_original_callback_finalization'): + validate_v1(damaged, closed, report) + with self.assertRaisesRegex(ValueError, 'reader_independent_native_final_callback'): + validate_v1(rows[:2], closed, report) + + def test_v2_actual_marker_and_closure_contract(self): + ready = {'firstReaderClosureObserved': True, 'actualAbortRequested': True} + closed = dict(actualSDKDone=True, actualAbortRequested=True, sdkFacts=0, + **{k: 2 for k in ('subscriptions', 'actualNativeReaderClosures', + 'registrations', 'actualRegistrationDisposals', 'markerReads')}) + rows = [] + for number in (1, 2): + envelope = {'id': 'h:' + str(number) * 24, 'created': 100.5 + number, + 'type': 'rpc.test.' + str(number), 'data': {'nonce': 'h:' + str(number + 2) * 24}} + rows.extend([{'kind': 'rpc-register', 'value': {'number': number, 'type': envelope['type']}}, + {'kind': 'native-v2', 'value': envelope}, + {'kind': 'rpc-marker-consumed', 'value': {'number': number, + 'markerReads': number, 'envelope': envelope}}]) + if number == 1: + rows.append({'kind': 'requested-native-abort', 'value': {'number': 1}}) + rows.extend([{'kind': 'native-reader-close', 'value': {'number': number, 'actualSignalAborted': number == 1}}, + {'kind': 'rpc-disposed', 'value': {'number': number}}]) + report = {'factoryFinalizationObserved': False} + validate_v2(rows, ready, closed, report) + for kind in ('native-v2', 'requested-native-abort', 'native-reader-close', 'rpc-disposed'): + damaged = copy.deepcopy(rows) + damaged.pop(next(i for i, r in enumerate(damaged) if r['kind'] == kind)) + with self.subTest(kind=kind), self.assertRaises(ValueError): + validate_v2(damaged, ready, closed, report) + damaged = copy.deepcopy(rows) + marker = next(r['value'] for r in damaged if r['kind'] == 'rpc-marker-consumed') + marker['envelope'] = dict(marker['envelope'], created=500) + with self.assertRaises(ValueError): + validate_v2(damaged, ready, closed, report) + with self.assertRaises(ValueError): + validate_v2(rows, ready, dict(closed, actualSDKDone=False), report) + damaged = copy.deepcopy(rows) + callback = damaged.pop(next(i for i, r in enumerate(damaged) if r['kind'] == 'native-v2')) + damaged.append(callback) + with self.assertRaisesRegex(ValueError, 'reader_native_callback_before_marker'): + validate_v2(damaged, ready, closed, report) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--self-test', action='store_true') + for name in ('manifest', 'clock-report', 'reader-report', 'source-root', 'output'): + parser.add_argument('--' + name, type=Path) + for name in ('manifest-sha256', 'clock-report-sha256', 'reader-report-sha256', 'os', 'arch', 'version'): + parser.add_argument('--' + name) + args = parser.parse_args() + if args.self_test: + result = unittest.main(argv=['business-proof'], exit=False) + need(result.result.wasSuccessful(), 'inert_contract_tests_failed') + return + need(all(v is not None for k, v in vars(args).items() if k != 'self_test'), 'all_inputs_required') + cell = {'os': args.os, 'arch': args.arch, 'version': args.version} + need(tuple(cell.values()) in CELLS, 'supported_partial_cell') + manifest, _ = read(args.manifest, args.manifest_sha256) + expected = selected_manifest(manifest, args.manifest_sha256, cell) + source = args.source_root.absolute() + leaf(source, manifest['assets']['embedded']['path'], expected['embeddedSHA256']) + clock, clock_body = read(args.clock_report, args.clock_report_sha256) + reader, reader_body = read(args.reader_report, args.reader_report_sha256) + module = validator(source) + validate_clock(clock, expected, cell, source, module) + validate_reader(reader, expected, cell, source) + # Every semantic/custody check finishes before the exclusive output exists. + output = args.output.absolute() + need(not any(p.is_symlink() for p in (output, *output.parents)), 'output_symlink') + output.mkdir(mode=0o700, parents=False, exist_ok=False) + (output / 'primitives').mkdir(mode=0o700) + records = {} + for name, body in [('clock', clock_body), ('reader', reader_body)]: + relative = 'primitives/' + name + '.json' + with (output / relative).open('xb') as stream: + stream.write(body) + (output / relative).chmod(0o600) + records[name] = {'file': relative, 'sha256': digest(body)} + proof = {'schema': 1, 'purpose': 'TEST portable installed business prerequisites', + 'candidateCommit': CANDIDATE, 'embeddedSHA256': expected['embeddedSHA256'], + 'cell': cell, 'primitives': records} + body = canonical(proof) + b'\n' + with (output / 'business-proof.json').open('xb') as stream: + stream.write(body) + (output / 'business-proof.json').chmod(0o600) + print(json.dumps({'proof': str(output / 'business-proof.json'), 'sha256': digest(body)})) + + +if __name__ == '__main__': + main() diff --git a/scripts/release-opencode-clock.py b/scripts/release-opencode-clock.py new file mode 100644 index 000000000..1447e84ec --- /dev/null +++ b/scripts/release-opencode-clock.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""CI-only fresh finite clock observations of immutable v1.48.1 artifacts. + +The exact candidate's stage_case/run_case and native predicates are unchanged. +Only external orchestration, original archive reuse and helper artifact reuse +replace preparation. This is no runtime/TimePolicy qualification or full gate. +--self-test is inert: synthetic metadata and namespace identities only. +""" +import argparse +import _thread +import hashlib +import os +from pathlib import Path +import platform +import re +import shutil +import socket +import tarfile +import threading +import time +import types + +CANDIDATE = 'e86149a724f9b96cb5ee193d63dbc5a533c61e7e' +ORIGINAL_RUN_ID = '37519336110' +ORIGINAL_ARCHIVE_SHA256 = 'ea156e1978d5acad56ff6cfbebc8cb20ba646b55a271e137dc696e6646bc64d9' +HARNESS_SHA256 = '1c30c5e625ef4b59435a4231ef9bc45882ceacb6dcb16fa0419ccffada6c72e0' +HARNESS = 'scripts/opencode-platform-clock-prequalification.py' +RELEASE_CELLS = {(o, a, v) for o, a in (('linux', 'amd64'), ('linux', 'arm64'), ('windows', 'amd64')) + for v in ('1.18.33', '2.0.21')} +RELEASE_CELLS.add(('linux', 'amd64', '1.18.34')) + + +def need(ok, reason): + if not ok: + raise RuntimeError(reason) + + +def native_namespace(host, current, interfaces): + need(re.fullmatch(r'net:\[[1-9][0-9]*\]', host or '') and + re.fullmatch(r'net:\[[1-9][0-9]*\]', current or ''), 'native_namespace_identity') + need(host != current and set(interfaces) == {'lo'}, 'native_private_loopback_namespace') + return {'host': host, 'private': current, 'interfaces': ['lo']} + + +def build_settings(raw, os_name, arch): + """Actual go version -m evidence, never a manifest's claimed build settings.""" + text = raw.decode('utf8') + need(re.search(r'^.+: go1\.27\.1\r?$', text, re.M), 'original_pinned_go_version') + pairs = re.findall(r'^\s*build\s+([^=\s]+)=(.*?)(?:\r)?$', text, re.M) + settings = dict(pairs) + need(len(settings) == len(pairs), 'duplicate_go_build_setting') + expected = {'vcs.revision': CANDIDATE, 'vcs.modified': 'false', 'vcs': 'git', + 'GOOS': os_name, 'GOARCH': arch, 'CGO_ENABLED': '1', '-trimpath': 'true', + '-buildmode': 'exe', '-compiler': 'gc'} + need(all(settings.get(k) == v for k, v in expected.items()), 'exact_original_native_go_build') + need(re.search(r'^\s*path\s+github\.com/777genius/agent-notifications/cmd/claude-notifications\r?$', text, re.M), + 'original_notification_command') + need(not re.search(r'^\s*=>', text, re.M), 'original_go_replacement_forbidden') + dependencies = re.findall(r'^\s*dep\s+(\S+)\s+(\S+)\s+(\S+)\s*$', text, re.M) + need(len({x[0] for x in dependencies}) == len(dependencies), 'duplicate_original_go_dependency') + return settings, {path: (version, checksum) for path, version, checksum in dependencies} + + +def exact_harness(repo): + path = repo / HARNESS + need(path.is_file() and not any(p.is_symlink() or + (hasattr(p, 'is_junction') and p.is_junction()) for p in (path, *path.parents)), 'exact_harness_path') + raw = path.read_bytes() + # Git's Windows checkout conversion is the sole permitted byte transform. + need(hashlib.sha256(raw.replace(b'\r\n', b'\n')).hexdigest() == HARNESS_SHA256, + 'immutable_candidate_harness_hash') + # Execute only the bytes just verified: import loaders may read a stale + # cached .pyc, and the immutable checkout must never receive new pycache. + module = types.ModuleType('immutable_release_clock_harness') + module.__file__ = str(path) + exec(compile(raw, str(path), 'exec'), module.__dict__) + need(module.REPO == repo and all(v is False for v in module.QUALIFICATIONS.values()), 'exact_harness_binding') + return module, hashlib.sha256(raw).hexdigest() + + +def custody(h, args): + """Bind downloaded manifest bytes to the immutable original parent archive.""" + archive = h.canonical_path(args.parent_archive) + manifest_path = h.canonical_path(args.manifest) + need(re.fullmatch('[a-f0-9]{64}', args.parent_archive_sha256 or '') and + archive.is_file() and 0 < archive.stat().st_size <= 2 * 1024**3 and + h.sha(archive) == args.parent_archive_sha256, 'original_parent_archive_hash') + need(re.fullmatch('[a-f0-9]{64}', args.manifest_sha256 or '') and + manifest_path.is_relative_to(h.REPO / '.task-tools/artifacts') and + h.sha(manifest_path) == args.manifest_sha256, 'original_manifest_hash') + manifest = h.load(manifest_path) + need(manifest.get('schema') == 1 and manifest.get('purpose') == 'TEST installed AN dual native' and + manifest.get('releaseScope') == 'linux-windows' and + manifest.get('candidateCommit') == manifest.get('buildRevision') == CANDIDATE, + 'original_seven_cell_candidate_custody') + need(isinstance(manifest.get('cells'), list) and len(manifest['cells']) == 7 and + {(c['os'], c['arch'], c['version']) for c in manifest['cells']} == RELEASE_CELLS, 'seven_release_cells_only') + wanted = {'manifest.json', 'release-inputs-receipt.json'} + records = {} + with tarfile.open(archive, 'r:gz') as packed: + seen = set(); total = 0 + for member in packed: + h.archive_name(member.name) + need(member.isfile() and member.name not in seen and len(seen) < 128 and + 0 <= member.size <= 536870912, 'original_parent_archive_members') + seen.add(member.name); total += member.size + need(total <= 2 * 1024**3, 'original_parent_archive_size') + if member.name in wanted: + need(member.size <= 1048576, 'original_parent_metadata_bound') + with packed.extractfile(member) as source: + records[member.name] = source.read(member.size + 1) + need(set(records) == wanted and records['manifest.json'] == manifest_path.read_bytes(), + 'manifest_from_original_parent_archive') + receipt = h.parse(records['release-inputs-receipt.json']) + need(str(receipt.get('runId')) == args.original_run_id and receipt.get('candidateCommit') == CANDIDATE and + receipt.get('releaseScope') == 'linux-windows' and receipt.get('macOSOpenCodeDesktopQualified') is False, + 'original_release_run_receipt') + return manifest + + +def checked_record(h, record): + need(isinstance(record, dict) and set(record) == {'path', 'sha256'} and + isinstance(record['path'], str) and re.fullmatch('[a-f0-9]{64}', record['sha256'] or ''), 'original_artifact_record') + h.archive_name(record['path']) + path = h.canonical_path(h.REPO / record['path']) + need(path.is_relative_to(h.REPO) and path.is_file() and h.sha(path) == record['sha256'], 'original_artifact_hash') + return path + + +def reuse_helper(h, owned, env, root, args, manifest): + cells = [c for c in manifest['cells'] if (c['os'], c['arch']) == (args.os, args.arch)] + records = [c['candidate'] for c in cells] + need(records and all(record == records[0] for record in records), 'one_original_platform_binary') + binary = checked_record(h, records[0]) + name = f'claude-notifications-{args.os}-{args.arch}' + ('.exe' if args.os == 'windows' else '') + need(binary == h.canonical_path(h.REPO / 'dist' / name), 'normal_original_release_binary_path') + with binary.open('rb') as stream: + h.native_header(stream.read(4096), args.os, args.arch) + if args.os == 'windows': + go_root = h.canonical_path(Path(owned.command(['go', 'env', 'GOROOT'], root, env).decode().strip()).resolve(strict=True)) + go_exe = h.canonical_path(go_root / 'bin/go.exe') + selected = shutil.which('go', path=env['PATH']) + need(selected and go_exe.samefile(selected), 'same_native_go_toolchain') + env = dict(env, GOROOT=str(go_root), PATH=str(go_root / 'bin') + os.pathsep + env['PATH']) + version = owned.command(['go', 'version'], root, env) + need(re.fullmatch(rb'go version go1\.27\.1 ' + args.os.encode() + b'/' + args.arch.encode() + rb'\r?\n', version), + 'pinned_native_validation_go') + info = owned.command(['go', 'version', '-m', str(binary)], root, env) + settings, binary_modules = build_settings(info, args.os, args.arch) + dependencies = h.json_stream(owned.command(['go', 'list', '-deps', '-json', './cmd/claude-notifications'], + h.REPO, env, 120, 16777216)) + owned.command(['go', 'mod', 'verify'], h.REPO, env, 60) + leaves, modules = [], {} + for package in dependencies: + need(not package.get('Error') and not package.get('Incomplete'), 'complete_go_dependency_closure') + directory = h.canonical_path(package['Dir'], 'go_dependency_directory') + module = package.get('Module'); identity = 'native_go_toolchain_stdlib' + if module: + need(not module.get('Replace'), 'unreviewed_go_module_replace') + identity = module['Path'] + '@' + module.get('Version', 'checkout') + if identity not in modules: + modules[identity] = {'path': module['Path'], 'version': module.get('Version'), + 'sum': module.get('Sum'), 'goModSHA256': h.sha(h.canonical_path(module['GoMod']))} + for category in ('GoFiles', 'CgoFiles', 'CFiles', 'CXXFiles', 'MFiles', 'HFiles', 'FFiles', 'SFiles', 'SysoFiles', 'EmbedFiles'): + for name in package.get(category, []): + path = h.canonical_path(directory / name, 'go_dependency_leaf') + need(path.is_relative_to(directory) and path.is_file(), 'original_go_leaf_containment') + leaf = {'package': package['ImportPath'], 'category': category, 'leaf': name, + 'actualSHA256': h.sha(path), 'actualBytes': path.stat().st_size, 'origin': identity} + if path.is_relative_to(h.REPO): + leaf.update(h.git_leaf(owned, env, path.relative_to(h.REPO).as_posix(), CANDIDATE, args.os)) + leaves.append(leaf) + observed_modules = {v['path']: (v['version'], v['sum']) for v in modules.values() if v['version'] is not None} + need(observed_modules == binary_modules, 'original_binary_dependency_closure') + need(all(any(x.get('path') == p for x in leaves) for p in ('cmd/claude-notifications/main.go', + 'cmd/claude-notifications/opencode_clock.go', 'internal/opencodeevent/clockprotocol.go')), 'actual_go_main_e0_closure') + helper = root / ('helper.exe' if args.os == 'windows' else 'helper') + shutil.copyfile(binary, helper); helper.chmod(0o700) + need(h.sha(helper) == records[0]['sha256'], 'original_helper_copy_identity') + closure = {'leaves': leaves, 'modules': modules, 'buildInfoSHA256': h.digest(info), + 'binarySHA256': records[0]['sha256'], 'goVersion': version.decode().strip(), 'sourceCommit': CANDIDATE, + 'CGO_ENABLED': '1', 'vcsModified': False, 'artifactReused': True, 'buildPerformed': False, + 'originalBinaryPath': records[0]['path'], 'originalRunID': args.original_run_id, + 'originalParentArchiveSHA256': args.parent_archive_sha256, 'originalBuildSettings': settings, + 'closureMethod': 'fresh_go_list_deps_and_go_mod_verify_bound_to_original_binary_modules', + 'validationCompilerSHA256': h.sha(h.canonical_path(Path(shutil.which('go', path=env['PATH'])).resolve(strict=True)))} + h.write_json(root / 'go-artifact-reuse-closure.json', closure) + return helper, closure + + +def execute(args): + need(os.environ.get('GITHUB_ACTIONS') == 'true', 'explicit_github_actions_execution_only') + need(not any(value for key, value in os.environ.items() if key in ('GH_TOKEN', 'GITHUB_TOKEN') or + key.endswith('_API_KEY')), 'credentials_forbidden_in_native_adapter') + need(os.environ.get('SOURCE_COMMIT') == CANDIDATE, 'immutable_release_source_commit') + need(args.original_run_id == ORIGINAL_RUN_ID and args.parent_archive_sha256 == ORIGINAL_ARCHIVE_SHA256, + 'immutable_original_release_run_and_archive') + need((args.os, args.arch, args.version) in RELEASE_CELLS, 'seven_release_cells_only') + actual_os = {'Linux': 'linux', 'Windows': 'windows'}.get(platform.system()) + actual_arch = {'x86_64': 'amd64', 'AMD64': 'amd64', 'aarch64': 'arm64', 'arm64': 'arm64'}.get(platform.machine()) + need((actual_os, actual_arch) == (args.os, args.arch), 'actual_native_host') + h, actual_harness_sha = exact_harness(args.candidate_repo.resolve(strict=True)) + started = time.monotonic(); job_end = started + h.BUDGETS['jobSeconds'] + watchdog = threading.Timer(h.BUDGETS['jobSeconds'], _thread.interrupt_main) + watchdog.daemon = True; watchdog.start() + owned = h.Owned(job_end); source_leaves = []; build = None; stage = 'original_custody' + report = {'schema': 1, 'purpose': 'fresh_release_artifact_platform_clock_observation', 'status': 'unqualified', + 'qualificationLevel': 'module_prequalification_only', 'executionOwner': 'parent_ci', + 'nativeWorkerExecution': False, 'platform': args.os, 'arch': args.arch, 'cases': [], + 'selectedCell': {'os': args.os, 'arch': args.arch, 'version': args.version}, + 'plannedVersions': [args.version], 'budgets': h.BUDGETS, 'reviewedBase': h.BASE, + 'expectedSourceCommit': CANDIDATE, + 'immutableHarnessGitBlobSHA256': HARNESS_SHA256, 'immutableHarnessActualSHA256': actual_harness_sha, + 'adapterSHA256': h.sha(Path(__file__).resolve()), 'originalRunID': args.original_run_id, + 'originalParentArchiveSHA256': args.parent_archive_sha256, 'originalManifestSHA256': args.manifest_sha256, + 'manifestSHA256': args.manifest_sha256, + 'preparationDeviations': ['single_requested_release_cell', 'original_release_helper_and_archive_reuse', + 'fresh_dependency_closure_without_build'] + (['externally_isolated_unprivileged_linux_namespace'] if args.os == 'linux' else []), + 'operationalAssumptions': {'trustedGlobalsAndBuiltins': True, 'immutableProtectedMappings': True, + 'windowsProtectedDefaultSystem32Win64APISet': args.os == 'windows', + 'linuxTrustedProcMountAndCallingThreadTimeNamespace': args.os == 'linux', + 'sampleObservationsAreNotFutureWallRateSuspendOrComparisonBounds': True}, + 'modelCalls': 0, 'providerCalls': 0, 'sessionCreates': 0, 'installerCalls': 0, + 'suspendExperiments': 0, 'qualificationRowsFilled': 0, 'buildPerformed': False, **h.QUALIFICATIONS} + try: + root = h.private_root(args.temp_base) + env = h.environment(root, tooling=True) + # Dependencies are pre-fetched in the trusted CI preparation phase. + # Fresh closure reading cannot fetch or change any module/source bytes. + cache = h.canonical_path(args.go_module_cache.resolve(strict=True), 'prefetched_go_module_cache') + env.update(GOMODCACHE=str(cache), GOPROXY='off', GOSUMDB='off', GOFLAGS='-mod=readonly') + h.checkout(owned, env, CANDIDATE) + dirty = h.git(owned, env, 'ls-files', '--others', '--exclude-standard', '--', '.', ':(exclude).task-tools/artifacts') + need(not dirty.strip(), 'clean_untracked_candidate_checkout') + manifest = custody(h, args) + embedded = checked_record(h, manifest['assets']['embedded']) + need(embedded == h.canonical_path(h.REPO / 'internal/opencodeplugin/dist/agent-notifications.js'), + 'original_embedded_asset_path') + report['embeddedSHA256'] = manifest['assets']['embedded']['sha256'] + source_leaves = h.source_closure(owned, env, CANDIDATE, args.os, root) + report.update(actualGitHEAD=CANDIDATE, cleanTrackedCheckout=True, checkoutLeaves=source_leaves, + checkoutClosureSHA256=h.digest(h.canonical(source_leaves))) + stage = 'original_helper_dependency_closure' + helper, build = reuse_helper(h, owned, env, root, args, manifest) + report.update(goBuildClosure=build, goBuildClosureSHA256=h.digest(h.canonical(build)), artifactReused=True, + candidateSHA256=build['binarySHA256']) + cell = next(c for c in manifest['cells'] if (c['os'], c['arch'], c['version']) == (args.os, args.arch, args.version)) + archive = checked_record(h, cell['archive']) + item = {'archive': cell['hostURL'], 'archiveSHA256': cell['archive']['sha256'], + 'binarySHA256': cell['executableSHA256'], 'sri': cell['archiveSRI']} + need(cell['hostSourceCommit'] == h.VERSIONS[args.version][0], 'original_stock_origin_commit') + if args.version == '1.18.34': + pin = re.search(r"\['1\.18\.34', '([a-f0-9]{64})'\]", (h.REPO / 'opencode-plugin/clock-cells.mjs').read_text()) + need(pin and pin[1] == cell['executableSHA256'], 'existing_current_linux_image_pin') + item = h.current_primary(dict(item, version=args.version, commit=cell['hostSourceCommit'], bun='1.3.14', + os='linux', arch='x64'), pin[1]) + else: + reviewed = h.closed_inputs(h.load(h.REPO / 'scripts/testdata/opencode-clock-qualification/closed-inputs.json')) + matches = [v for v in reviewed['artifacts'] if (v['version'], v['os'], v['arch']) == + (args.version, args.os, 'x64' if args.arch == 'amd64' else 'arm64')] + need(len(matches) == 1 and all(matches[0][k] == item[k] for k in item), 'immutable_reviewed_stock_archive') + + def original_archive(_owned, _env, case_root, requested): + need(all(requested[k] == item[k] for k in item), 'requested_original_archive_identity') + copied = case_root / 'official.tgz'; shutil.copyfile(archive, copied); copied.chmod(0o600) + need(h.sha(copied) == item['archiveSHA256'], 'original_stock_archive_copy') + import base64 + with copied.open('rb') as stream: + sri = 'sha512-' + base64.b64encode(hashlib.file_digest(stream, 'sha512').digest()).decode() + need(sri == item['sri'], 'original_stock_archive_sri') + return copied + + h.fetch_archive = original_archive + stage = 'original_case_staging' + case_root, metadata = h.stage_case(owned, env, root, args.version, item, source_leaves, helper, + build['binarySHA256'], CANDIDATE, args.os, args.arch, cell['hostSourceCommit']) + report['caseMetadata'] = metadata + if args.os == 'linux': + need(os.geteuid() != 0, 'unprivileged_native_fixture') + report['nativeNamespaceProof'] = native_namespace(os.environ.get('AN_HOST_NETNS', ''), + os.readlink('/proc/self/ns/net'), [name for _, name in socket.if_nameindex()]) + stage = 'actual_native_observation' + result = h.run_case(case_root, metadata, args.os, args.arch, job_end) + report['cases'].append(result) + need(result['status'] == 'module_prequalification_observed' and + all(result[k] is False for k in h.QUALIFICATIONS), 'original_native_case_required') + # Preserve the genuine semantic result, including its original kind. + # The immutable harness normally exposes only its validated projection. + stream_path = case_root / 'module-stream-0.private' + transcript = stream_path.read_bytes() + need(0 < len(transcript) <= 65536, 'original_module_stream_bound') + frames = [h.parse(line, h.BUDGETS['frameBytes']) for line in transcript.splitlines()] + disposed = [frame for frame in frames if frame.get('kind') == 'disposed'] + need(len(disposed) == 1, 'one_genuine_disposed_result') + h.validate_result(disposed[0], args.os) + need(all(result[k] == v for k, v in disposed[0].items() if k != 'kind'), 'original_result_projection_identity') + result.update(actualModuleResult=disposed[0], originalModuleStreamSHA256=h.digest(transcript)) + h.checkout(owned, env, CANDIDATE) + need(all(h.sha(h.REPO / v['path']) == v['actualSHA256'] for v in source_leaves), 'final_source_closure') + need(h.sha(checked_record(h, cell['candidate'])) == build['binarySHA256'], 'final_original_helper_identity') + need(h.sha(embedded) == report['embeddedSHA256'], 'final_original_embedded_identity') + h.remaining(job_end) + report['status'] = 'module_prequalification_observed' + except Exception as error: + report.update(status='unqualified', failureReason=h.failure_reason(error), + failureObservation={'stage': stage, 'parentElapsedMs': round((time.monotonic() - started) * 1000, 3)}) + except KeyboardInterrupt: + report.update(status='unqualified', failureReason='absolute_deadline' if time.monotonic() >= job_end else 'parent_interrupted') + finally: + watchdog.cancel(); owned.cleanup(); report['parentOwnership'] = owned.summary() + report['helperStarts'] = sum(c.get('helperStarts', 0) for c in report['cases']) + report['helperActualCloses'] = sum(c.get('helperActualCloses', 0) for c in report['cases']) + report['maxOwnedWeightIncludingSupervisor'] = max([owned.highwater] + [c.get('maxOwnedWeight', 0) for c in report['cases']]) + report['allOwnedHandlesClosed'] = not owned.live and all(c.get('allOwnedHandlesClosed') is True for c in report['cases']) + if report['status'] == 'module_prequalification_observed' and not (report['helperStarts'] == report['helperActualCloses'] == 3 and + report['allOwnedHandlesClosed'] and report['maxOwnedWeightIncludingSupervisor'] <= 4): + report.update(status='unqualified', failureReason='cell_lifecycle_counts') + if time.monotonic() >= job_end: + report.update(status='unqualified', failureReason='absolute_deadline') + h.write_json(args.report, report) + if time.monotonic() >= job_end and report['status'] != 'unqualified': + report.update(status='unqualified', failureReason='absolute_deadline') + late = args.report.with_name(args.report.name + '.owned-late') + h.write_json(late, report); os.replace(late, args.report) + return 0 if report['status'] == 'module_prequalification_observed' else 1 + + +def self_test(): + # These fail if reused/externally connected namespaces or wrong build + # revision/CGO/platform metadata become admissible. No runtime is launched. + native_namespace('net:[10]', 'net:[11]', ['lo']) + for values in [('net:[10]', 'net:[10]', ['lo']), ('bad', 'net:[11]', ['lo']), + ('net:[10]', 'net:[11]', ['lo', 'eth0'])]: + try: + native_namespace(*values) + except RuntimeError: + continue + raise AssertionError('unsafe_namespace_accepted') + info = ('synthetic: go1.27.1\n\tpath\tgithub.com/777genius/agent-notifications/cmd/claude-notifications\n' + + ''.join('\tbuild\t' + k + '=' + v + '\n' for k, v in {'vcs.revision': CANDIDATE, + 'vcs.modified': 'false', 'vcs': 'git', 'GOOS': 'linux', 'GOARCH': 'amd64', 'CGO_ENABLED': '1', + '-trimpath': 'true', '-buildmode': 'exe', '-compiler': 'gc'}.items())).encode() + build_settings(info, 'linux', 'amd64') + for old, new in [(CANDIDATE.encode(), b'0' * 40), (b'vcs.modified=false', b'vcs.modified=true'), + (b'CGO_ENABLED=1', b'CGO_ENABLED=0'), (b'GOARCH=amd64', b'GOARCH=arm64')]: + try: + build_settings(info.replace(old, new), 'linux', 'amd64') + except RuntimeError: + continue + raise AssertionError('wrong_original_build_accepted') + print('PASS inert namespace and original Go metadata admission') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + modes = parser.add_mutually_exclusive_group(required=True) + modes.add_argument('--self-test', action='store_true'); modes.add_argument('--execute-ci', action='store_true') + for name in ('candidate-repo', 'temp-base', 'report', 'manifest', 'parent-archive', 'go-module-cache'): + parser.add_argument('--' + name, type=Path) + parser.add_argument('--os', choices=('linux', 'windows')) + parser.add_argument('--arch', choices=('amd64', 'arm64')) + parser.add_argument('--version', choices=('1.18.33', '2.0.21', '1.18.34')) + for name in ('manifest-sha256', 'parent-archive-sha256', 'original-run-id'): + parser.add_argument('--' + name) + args = parser.parse_args(); os.umask(0o077) + if args.self_test: + self_test(); return 0 + need(all(v is not None for k, v in vars(args).items() if k not in ('self_test', 'execute_ci')), 'explicit_ci_inputs_required') + return execute(args) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/release-opencode-inputs.py b/scripts/release-opencode-inputs.py new file mode 100644 index 000000000..f2adf7880 --- /dev/null +++ b/scripts/release-opencode-inputs.py @@ -0,0 +1,185 @@ +"""Seal same-run release custody from actual reviewed source bytes, never grants. + +This tool downloads pinned inputs and reads build metadata, but never launches a +candidate, native host, provider or notifier. Execution belongs to release CI. +""" +import argparse +import base64 +import gzip +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +import urllib.parse +import urllib.request + +REPO = Path(__file__).resolve().parents[1] +FIXTURES = REPO / 'scripts/testdata/opencode-native-e2e' +SPEC = importlib.util.spec_from_file_location('release_ci_inputs', FIXTURES / 'ci_inputs.py') +inputs = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(inputs) +r = inputs.r +PARENT = '.task-tools/artifacts/parent-inputs' +LIMIT = 2 * 1024**3 +SDK_ARCHIVE_SHA256 = 'c3d5aaaf6ecc3116b48ab1ae3f0e00b47720f239df9938f0c499d03f2c21a752' +SDK_INDEX_SHA256 = '8e0c343aa9ea29bfce3e4d53c0d979ffc447af96c36b10c5253e3a766a6454a5' +# Exact source identity retained in the canonical sealed all-eleven parent. +# The immutable official archive/image identities remain checked independently. +LINUX_CURRENT_HOST_SOURCE = 'aec0b9a6d8898f68f923aaf08b7306d931fd9d76' + + +def download(url, destination, expected): + parsed = urllib.parse.urlsplit(url) + r.require(parsed.scheme == 'https' and parsed.hostname and not parsed.username + and not parsed.password and not parsed.query and not parsed.fragment + and re.fullmatch('[0-9a-f]{64}', str(expected)), 'input_url_or_digest_invalid') + destination.parent.mkdir(parents=True, exist_ok=True) + total = 0 + with urllib.request.urlopen(url, timeout=60) as source, destination.open('xb') as out: + while block := source.read(1024 * 1024): + total += len(block) + r.require(total <= LIMIT, 'input_archive_size') + out.write(block) + r.require(r.digest(destination) == expected, 'input_archive_hash_mismatch') + + +def reviewed_sdk_index(archive, output): + """Retain raw package/index.js, matching the original canonical custody.""" + with tarfile.open(archive, 'r:gz') as packed: + matches = [member for member in packed.getmembers() if member.name == 'package/index.js'] + r.require(len(matches) == 1 and matches[0].isfile() and matches[0].size == 311, + 'reviewed_sdk_index_member_invalid') + with packed.extractfile(matches[0]) as source: + body = source.read() + r.require(hashlib.sha256(body).hexdigest() == SDK_INDEX_SHA256, 'reviewed_sdk_index_differs') + (output / 'sdk-index.js').write_bytes(body) + + +def custody_sources(archive, output): + reviewed_sdk_index(archive, output) + return { + 'sdkSource': {'path': PARENT + '/sdk-index.js', 'sha256': SDK_INDEX_SHA256}, + # This is actual current production Go source, not a historical grant. + 'qualificationSource': record(REPO / 'internal/opencodecodec/qualification_rows.go'), + } + + +def record(path): + r.require(path.is_file() and not path.is_symlink(), 'release_input_not_regular') + return {'path': str(path.relative_to(REPO)), 'sha256': r.digest(path)} + + +def seal_archive(root, archive): + """Canonical tar metadata makes archive identity independent of runner time.""" + members = sorted(root.rglob('*')) + r.require(not any(p.is_symlink() for p in members), 'parent_member_symlink') + files = [p for p in members if p.is_file()] + r.require(files and sum(p.stat().st_size for p in files) <= LIMIT, 'parent_archive_size') + with archive.open('xb') as raw, gzip.GzipFile(fileobj=raw, mode='wb', filename='', mtime=0) as zipped: + with tarfile.open(fileobj=zipped, mode='w', format=tarfile.USTAR_FORMAT) as packed: + for path in files: + r.require(not path.is_symlink(), 'parent_member_symlink') + member = tarfile.TarInfo(str(path.relative_to(root))) + member.size, member.mode = path.stat().st_size, 0o600 + with path.open('rb') as body: + packed.addfile(member, body) + return r.digest(archive) + + +def release_manifest(scope): + manifest = json.loads((FIXTURES / 'manifest.template.json').read_text()) + r.require(scope in ('all', 'linux-windows'), 'unsupported_release_scope') + if scope == 'linux-windows': + manifest['releaseScope'] = scope + manifest['cells'] = [cell for cell in manifest['cells'] if cell['os'] != 'darwin'] + r.custody_cells(manifest, ('linux', 'amd64', '1.18.33')) + # Check every requested binary before downloading hosts or sealing any bytes. + for cell in manifest['cells']: + name = f"claude-notifications-{cell['os']}-{cell['arch']}" + ('.exe' if cell['os'] == 'windows' else '') + cell['candidate'] = record(REPO / 'dist' / name) + return manifest + + +def prepare(rebuilt, commit, scope='all'): + manifest = release_manifest(scope) + root = inputs.artifacts / 'release-parent' + root.mkdir(mode=0o700) + embedded = REPO / 'internal/opencodeplugin/dist/agent-notifications.js' + r.require(rebuilt.read_bytes() == embedded.read_bytes(), 'rebuilt_bundle_differs') + shutil.copyfile(rebuilt, root / 'rebuilt-agent-notifications.js') + manifest['candidateCommit'] = manifest['buildRevision'] = commit + manifest['assets'] = { + 'embedded': record(embedded), + 'rebuilt': {'path': PARENT + '/rebuilt-agent-notifications.js', 'sha256': r.digest(rebuilt)}, + 'packageLock': record(REPO / 'opencode-plugin/package-lock.json'), + } + lock = json.loads((REPO / 'opencode-plugin/package-lock.json').read_text()) + dependency = lock['packages']['node_modules/universal-agent-plugins-opencode-events'] + resolved = dependency['resolved'] + r.require(resolved.startswith('file:vendor/'), 'release_sdk_must_be_reviewed_vendor') + sdk = (REPO / 'opencode-plugin' / resolved[5:]).resolve(strict=True) + r.require(sdk.is_relative_to(REPO / 'opencode-plugin/vendor'), 'sdk_path_escape') + r.require(r.digest(sdk) == SDK_ARCHIVE_SHA256, 'reviewed_sdk_archive_differs') + sri = 'sha512-' + base64.b64encode(hashlib.sha512(sdk.read_bytes()).digest()).decode() + r.require(dependency['integrity'] == sri, 'sdk_lock_sri_mismatch') + manifest['sdk'] = {'archive': record(sdk), 'sri': sri, 'lockResolved': resolved, 'lockIntegrity': dependency['integrity']} + manifest['assets'].update(custody_sources(sdk, root)) + for cell in manifest['cells']: + identity = (cell['os'], cell['arch'], cell['version']) + if cell['hostSourceCommit'] is None: + r.require(identity == ('linux', 'amd64', '1.18.34'), 'unknown_host_source_identity') + cell['hostSourceCommit'] = LINUX_CURRENT_HOST_SOURCE + r.require(re.fullmatch('[0-9a-f]{40}', str(cell['hostSourceCommit'])), 'host_source_missing') + binary = r.checked_file(REPO, cell['candidate']) + build = subprocess.check_output(['go', 'version', '-m', str(binary)], text=True) + r.verify_source_binding(manifest, commit, build, '') + directory = root / 'inputs' / '-'.join(identity) + directory.mkdir(parents=True) + archive = directory / 'package.tgz' + download(cell['hostURL'], archive, cell['archive']['sha256']) + r.require(r.sri(archive) == cell['archiveSRI'], 'host_archive_sri_mismatch') + image = directory / 'image' + r.extract_opencode(archive, image, cell['os']) + r.require(r.digest(image) == cell['executableSHA256'], 'host_executable_hash_mismatch') + image.unlink() + # Custody supplies no desktop qualification, including skipped macOS. + cell['nativeApp'] = None + helper = REPO / '.task-tools/artifacts/release-helper/ClaudeNotifier.app.zip' + receipt = {'purpose': 'same-run release input custody, no qualification grant', + 'candidateCommit': commit, 'runId': os.environ['GITHUB_RUN_ID'], + 'runAttempt': os.environ['GITHUB_RUN_ATTEMPT'], + 'releaseScope': scope, + 'includedPlatforms': sorted({cell['os'] + '/' + cell['arch'] for cell in manifest['cells']}), + 'skippedPlatforms': ['darwin/amd64', 'darwin/arm64'] if scope == 'linux-windows' else [], + 'signedHelperArchive': None if scope == 'linux-windows' else record(helper), + 'macOSOpenCodeDesktopQualified': False} + (root / 'release-inputs-receipt.json').write_text(json.dumps(receipt, indent=2, sort_keys=True) + '\n') + (root / 'manifest.json').write_text(json.dumps(manifest, indent=2, sort_keys=True) + '\n') + return seal_archive(root, inputs.artifacts / 'release-opencode-inputs.tar.gz') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--rebuilt', type=Path, required=True) + parser.add_argument('--scope', choices=('all', 'linux-windows'), default='all', + help='Explicit partial custody; default retains all eleven cells') + args = parser.parse_args() + inputs.artifacts.mkdir(parents=True, exist_ok=True) + commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=REPO, text=True).strip() + r.require(commit == os.environ.get('GITHUB_SHA'), 'exact_release_checkout_required') + dirty = subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=no'], cwd=REPO, text=True) + dirty += subprocess.check_output(['git', 'ls-files', '--others', '--exclude-standard', '--', '.', + ':(exclude).task-tools/artifacts'], cwd=REPO, text=True) + r.require(not dirty, 'clean_release_checkout_required') + sha = prepare(args.rebuilt, commit, args.scope) + with Path(os.environ['GITHUB_OUTPUT']).open('a') as out: + out.write('sha256=' + sha + '\n') + + +if __name__ == '__main__': + main() diff --git a/scripts/release-opencode-reader.mts b/scripts/release-opencode-reader.mts new file mode 100644 index 000000000..62702b6f5 --- /dev/null +++ b/scripts/release-opencode-reader.mts @@ -0,0 +1,246 @@ +// TEST-only public packed SDK consumer. No notification, helper or production grant. +import { createObserver } from 'universal-agent-plugins-opencode-events/v1'; +import { createV2Observer } from 'universal-agent-plugins-opencode-events/v2'; +import { createNativeV1 } from './candidate/opencode-plugin/native-v1.mjs'; +import { createNativeV2, createRPCCheckpoint } from './candidate/opencode-plugin/native-v2.mjs'; +import { readFileSync, realpathSync, statSync, openSync, fstatSync, closeSync, + appendFileSync, writeFileSync, renameSync } from 'node:fs'; +import { parseJSON } from './candidate/opencode-plugin/protocol.mjs'; +import type { ObserverOptions } from 'universal-agent-plugins-opencode-events/v1'; +import type { V2Context, V2CheckpointPort, V2NativePorts, V2Envelope } from 'universal-agent-plugins-opencode-events/v2'; +import type { BigIntStats } from 'node:fs'; +import { resolve, relative, isAbsolute, basename } from 'node:path'; +import { createHash, createHmac } from 'node:crypto'; + +// Runtime context is supplied by the genuine host. Only structural public ports +// are described here; no replacement native event source exists in this helper. +type NativeEvent = { type?: string; [key: string]: unknown }; +type V1Input = { client: ObserverOptions['client']; directory: string }; +type Registration = { events: { emit(type: string, value: { nonce: string }): Promise }; dispose(): Promise }; +type NativeContext = V2Context & { + location: { directory: string; workspaceID?: string; project: { id: string } }; + rpc: { register(input: unknown, options: unknown): Promise }; + session: { get(input: unknown): Promise; context(input: unknown): Promise }; +}; +type Authority = { version: string; os: 'linux' | 'windows'; arch: 'amd64' | 'arm64'; + official: { executableSHA256: string }; imageSHA256: string; parentPID: number; + directory: string; mode: 'api-only' | 'one-completion'; apiOnlyReadiness: boolean }; +const statKeys = ['dev','ino','size','mtimeNs','ctimeNs'] as const satisfies readonly (keyof BigIntStats)[]; + +const root = realpathSync(process.env.AN_TEST_ROOT || '/missing'); +const own = parseJSON(readFileSync(resolve(root,'reader-authority-private.json')), 4096) as Authority; +const marker = parseJSON(readFileSync(resolve(root,'.owned-test-root.json')),4096); +if (marker.purpose !== 'TEST portable packaged SDK reader' || !/^TEST-/.test(basename(root))) throw Error('TEST root'); +const project = realpathSync(resolve(root,'project')), key = readFileSync(resolve(root,'trace-key')); +const image = realpathSync(process.execPath), fd = openSync(image,'r'), imageStat = fstatSync(fd,{bigint:true}); +const hash = createHash('sha256').update(readFileSync(fd)).digest('hex'); +const inside = (path: string) => !relative(root,path).startsWith('..') && !isAbsolute(relative(root,path)); +if (!inside(image) || !inside(project) || hash !== own.official.executableSHA256 || own.imageSHA256 !== hash || + process.platform !== ({linux:'linux',windows:'win32'}[own.os]) || + process.arch !== ({amd64:'x64',arm64:'arm64'}[own.arch]) || process.ppid !== own.parentPID || + (process.platform==='linux' && realpathSync('/proc/self/exe') !== image) || own.directory !== project || key.length !== 32 || + !statKeys.every(k=>typeof imageStat[k]==='bigint')) throw Error('image custody'); +let valid = true, fdOpen = true, rows = 0, bytes = 0, nativeCalls = 0, sdkFacts = 0; +const h = (s: unknown) => 'h:'+createHmac('sha256',key).update(String(s)).digest('hex').slice(0,24); +const safe = new Set(['v1','v2','user','assistant','stop','turn_idle_verified','assistant_completed', + 'envelope_created','local-performance','subscription_ended','subscription_error','observer_disposed']); +function redact(value: unknown, name='', depth=0): unknown { + if (depth > 14) throw Error('trace depth'); + if (value === null || typeof value === 'boolean' || typeof value === 'number') return value; + if (typeof value === 'string') return safe.has(value) || name === 'type' && /^[a-z][a-z0-9_.-]{0,160}$/.test(value) ? value : h(value); + if (Array.isArray(value)) { if (value.length > 96) throw Error('trace array'); return value.map(x=>redact(x,name,depth+1)); } + if (value && typeof value === 'object') { + if (Object.keys(value).length > 96) throw Error('trace object'); + return Object.fromEntries(Object.entries(value).map(([k,x])=>[k,redact(x,k,depth+1)])); + } + return '[unsupported]'; +} +function record(kind: string,value: unknown={}) { + const line = JSON.stringify({kind,value:redact(value)})+'\n'; + if (++rows > 512 || (bytes += Buffer.byteLength(line)) > 1024*1024) { valid=false; throw Error('trace bound'); } + appendFileSync(resolve(root,'reader-private.jsonl'),line,{mode:0o600}); +} +// Raw exception fields stay in this bounded private TEST file, never exported. +let exceptionRows=0, exceptionBytes=0; +function checkpointException(stage: string,error: unknown) { + try { + const text=(value: unknown,limit: number)=>typeof value==='string'?Buffer.from(value).subarray(0,limit).toString('utf8'):undefined; + const detail=error instanceof Error?error:undefined; + const row={stage,name:text(detail?.name,64),message:text(detail?.message,1536),stack:text(detail?.stack,2048)}; + let line=JSON.stringify(row)+'\n'; + while(Buffer.byteLength(line)>4096) { + if(row.stack?.length)row.stack=row.stack.slice(0,Math.floor(row.stack.length/2)); + else row.message=row.message?.slice(0,Math.floor(row.message.length/2)); + line=JSON.stringify(row)+'\n'; + } + const size=Buffer.byteLength(line); + if(++exceptionRows>8 || (exceptionBytes+=size)>16384) { valid=false; return; } + appendFileSync(resolve(root,'checkpoint-errors.private.jsonl'),line,{mode:0o600}); + } catch { valid=false; } +} +function witness() { + if (!valid || !fdOpen) return false; + try { + const now=fstatSync(fd,{bigint:true}), named=statSync(image,{bigint:true}); + return statKeys.every(k=>now[k]===imageStat[k] && named[k]===imageStat[k]) && + (process.platform!=='linux' || realpathSync('/proc/self/exe')===image) && process.ppid===own.parentPID; + } catch { return false; } +} +function fail(reason='scope_uncertain') { valid=false; record('uncertainty',{reason}); } +function publish(name: string,value: unknown) { + if (!witness()) throw Error('authority lost'); + const target=resolve(root,name), tmp=target+'.tmp'; + writeFileSync(tmp,JSON.stringify(value)+'\n',{mode:0o600,flag:'wx'}); renameSync(tmp,target); +} +const slots = new WeakMap(); +function scopeOK(directory: string | undefined) { try { return witness() && typeof directory==='string' && realpathSync(directory)===project; } catch { return false; } } +function receipt(generation: string, extra: Record={}) { + return {schemaVersion:1,scope:'TEST packaged SDK reader',generation,version:own.version, + ownedImageSHA256:hash,os:own.os,arch:own.arch,nativePID:process.pid,parentPID:process.ppid,actualExecPath:image,testAuthorityDerived:true,loadedMappedBytesQualified:false,productionQualified:false, + installedQualified:false,timePolicyQualified:false,sourceEpochQualified:false,finalSpanQualified:false, fullNativeQualified:false,clockQualified:false,visibleDesktopQualified:false,platformLifetimeQualified:false,...extra}; +} +function diagnostic(reason: string) { record('sdk-diagnostic',{reason}); } +async function boundedDrain(task: Promise,deadline: number) { + let timer: ReturnType | undefined; + try { return await Promise.race([task,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('actual drain missing')),Math.max(0,deadline-performance.now()));})]); } + finally { clearTimeout(timer); } +} +async function server(input: V1Input) { + if (slots.has(input.client)) return slots.get(input.client); + if (own.version==='2.0.21' || !scopeOK(input.directory) || + typeof input.client?.session?.messages!=='function' || typeof input.client?.session?.get!=='function') throw Error('V1 native context'); + const jobs=new Set>(); let stopped=false, nativeCleanup=false, closing: Promise | undefined; + const view=createNativeV1(input.client,project,fail); + let observer: ReturnType; + async function stop(native=false) { + nativeCleanup ||= native; + if (closing) return closing; + const deadline=performance.now()+2000; + stopped=true; observer.dispose(); view.dispose(); + closing=(async()=>{ + await boundedDrain(Promise.allSettled([...jobs]),deadline); + if (jobs.size || performance.now()>deadline) throw Error('V1 drain'); + publish('reader-closed.json',receipt('v1',{actualSDKDispose:true,actualTrackedJobsSettled:true, + nativeCallbackReader:true,nativeCleanupHookObserved:nativeCleanup,sdkFacts,nativeCalls,closureCause:own.mode==='api-only'?'consumer_after_native_session_deleted':'consumer_after_real_sdk_fact'})); + record('reader-closed',{actualDrain:true}); closeSync(fd); fdOpen=false; + })(); return closing; + } + observer=createObserver({client:input.client,location:project,callbackAuthority:'qualified_native_sync', + runtimeEligibility:()=>scopeOK(input.directory)?'supported':'unverified', + beforeEmit:(event,handoff)=>scopeOK(input.directory)&&view.finalize(event,handoff),onDiagnostic:diagnostic, + emit(event,handoff) { + if (stopped || !scopeOK(input.directory) || !handoff.isCurrent() || handoff.signal.aborted || + event.kind!=='turn_idle_verified' || event.rootSession!==true || ++sdkFacts!==1) { fail('SDK fact rejected'); return; } + record('sdk-fact',{event,clockID:handoff.clockID,ingressMonotonicMs:handoff.ingressMonotonicMs, + currentAtCallback:handoff.isCurrent(),nativeCallbackCount:nativeCalls}); + if(own.mode!=='one-completion') { fail('unplanned_completion_fact'); return; } + queueMicrotask(()=>void stop().catch(()=>fail('consumer_dispose_failed'))); + }}); + const hooks={event({event}: { event: NativeEvent }) { + if (stopped) { record('after-dispose-native',{type:event?.type}); return; } + if (!scopeOK(input.directory)) { fail(); observer.dispose(); return; } + nativeCalls++; record('native-v1',event); view.ingest(event); + if (jobs.size>=256) { fail('job_capacity'); observer.dispose(); return; } + const job=observer.observe(event); jobs.add(job); + Promise.resolve(job).finally(()=>{jobs.delete(job);if(own.mode==='api-only'&&event?.type==='session.deleted') + queueMicrotask(()=>void stop().catch(()=>fail('consumer_dispose_failed')));}).catch(()=>fail('callback_failure')); + record('sdk-observe-invoked',{type:event?.type}); + },dispose:()=>stop(true)}; + slots.set(input.client,hooks); record('loader',{branch:'v1',scopeVerified:true}); + publish('reader-ready.json',receipt('v1',{nativeCallbackReady:true})); return hooks; +} +async function setup(ctx: NativeContext) { + if (slots.has(ctx)) return slots.get(ctx); + if (own.version!=='2.0.21' || ctx.app?.version!==own.version || !scopeOK(ctx.location?.directory) || + !ctx.location?.project?.id || typeof ctx.event?.subscribe!=='function' || typeof ctx.rpc?.register!=='function' || + typeof ctx.session?.get!=='function' || typeof ctx.session?.context!=='function') throw Error('V2 native context'); + let subscriptions=0, registrations=0, closures=0, disposedPorts=0, markerReads=0, cancelled=false, stopping=false, closing: Promise | undefined; + let firstController: AbortController | undefined, observer: ReturnType; + const scope=Object.freeze({directory:project,workspaceID:ctx.location.workspaceID, + project:Object.freeze({id:ctx.location.project.id})}), view=createNativeV2(ctx,scope,()=>{},()=>fail()); + const sameScope=()=>scopeOK(ctx.location?.directory) && ctx.location.project.id===scope.project.id && + ctx.location.workspaceID===scope.workspaceID; + const context: V2Context={app:ctx.app,event:{subscribe({signal}) { + if (++subscriptions>2) { fail('unexpected_reader_replacement'); throw Error('reader limit'); } + const number=subscriptions, ctrl=new AbortController(); if(number===1) firstController=ctrl; + const abort=()=>ctrl.abort(); signal.addEventListener('abort',abort,{once:true}); if(signal.aborted) abort(); + const stream=ctx.event.subscribe({signal:ctrl.signal}), iterator=stream[Symbol.asyncIterator](); let closed=false, returned: Promise> | undefined; + function closeObserved(cause: string) { + if(closed)return; closed=true; closures++; signal.removeEventListener('abort',abort); view.reset(); + record('native-reader-close',{number,cause,actualSignalAborted:ctrl.signal.aborted}); + } + function actualClose(cause: string) { + returned ??= (async()=>{ + try { + if(typeof iterator.return !== 'function')throw Error('native return absent'); + const result=await iterator.return(); + if(result?.done!==true)throw Error('native return did not finish'); + closeObserved(cause); return result; + } catch(error) { fail('native_reader_close_failed'); throw error; } + })(); + return returned; + } + record('native-reader-open',{number}); + const bridge: AsyncIterableIterator = {[Symbol.asyncIterator](){return this;},async next() { + try { const result=await iterator.next(); + if(result.done)await actualClose('actual_done'); + else { nativeCalls++; record('native-v2',result.value); } + return result; + } catch(error) { await actualClose('actual_error'); throw error; } + },async return() { return await actualClose('actual_return'); }}; + return bridge; + }}}; + const realPort=createRPCCheckpoint(ctx,async()=>sameScope()); + const checkpoint={async register(signal: AbortSignal,namespace: string) { + let port; try { port=await realPort.register(signal,namespace); } + catch(error) { checkpointException('native_registration',error); throw error; } + if(!port)return; + const number=++registrations, emitted=new Set(); record('rpc-register',{number,type:port.type}); + return {type:port.type,emit:(nonce: string)=>{ + try { emitted.add(nonce); } catch(error) { checkpointException('consumer_nonce_tracking',error); throw error; } + record('checkpoint-stage',{stage:'native_emit_started'}); + let pending; try { pending=port.emit(nonce); } + catch(error) { checkpointException('native_emit',error); throw error; } + return Promise.resolve(pending).then(value=>{record('checkpoint-stage',{stage:'native_emit_returned'});return value;}, + error=>{checkpointException('native_emit',error);throw error;}); + },read(envelope: V2Envelope) { + const nonce=port.read(envelope); if(nonce===undefined)return nonce; + if(!emitted.delete(nonce)) { fail('unexpected_checkpoint_nonce'); return undefined; } + markerReads++; record('rpc-marker-consumed',{number,markerReads,envelope}); + if(number===1 && !cancelled) { + cancelled=true; queueMicrotask(()=>{record('requested-native-abort',{number});if(!firstController)throw Error('first controller missing');firstController.abort();}); + } else if(number===2 && markerReads===2) { + publish('reader-ready.json',receipt('v2',{realMarkerConsumed:true,subscriptions,registrations, + firstReaderClosureObserved:closures===1,actualAbortRequested:cancelled})); + if(own.apiOnlyReadiness===true)queueMicrotask(()=>void stop().catch(()=>fail('consumer_dispose_failed'))); + } + return nonce; + },async dispose() { await port.dispose(); disposedPorts++; record('rpc-disposed',{number}); }}; + }}; + async function stop(native=false) { + if(closing)return closing; const deadline=performance.now()+2000; stopping=true; observer.dispose(); + closing=(async()=>{ + await boundedDrain(observer.done(),deadline); + if(performance.now()>deadline || subscriptions!==2 || closures!==2 || disposedPorts!==2) throw Error('V2 actual closure'); + publish('reader-closed.json',receipt('v2',{actualSDKDispose:true,actualSDKDone:true,subscriptions, + actualNativeReaderClosures:closures,registrations,actualRegistrationDisposals:disposedPorts, + markerReads,actualAbortRequested:cancelled,nativeCleanupHookObserved:native,sdkFacts,nativeCalls, + closureCause:own.apiOnlyReadiness===true?'consumer_after_actual_readiness_markers':'consumer_after_real_sdk_fact'})); + record('reader-closed',{actualDrain:true}); closeSync(fd); fdOpen=false; + })(); return closing; + } + observer=createV2Observer({context,location:project,native:view.native as V2NativePorts,checkpoint:checkpoint as V2CheckpointPort, + runtimeEligibility:version=>version===own.version&&sameScope()?'supported':'unverified',onDiagnostic:diagnostic, + beforeEmit:(event,handoff)=>sameScope()&&view.finalize(event,handoff), + emit(event,handoff) { + const projected=view.project(event); + if(stopping || !sameScope() || !handoff.isCurrent() || handoff.signal.aborted || + !projected || event.kind!=='turn_idle_verified' || event.rootSession!==true || ++sdkFacts!==1) { fail('SDK fact rejected'); return; } + record('sdk-fact',{event,projected,clockID:handoff.clockID,ingressMonotonicMs:handoff.ingressMonotonicMs, + currentAtCallback:handoff.isCurrent(),nativeCallbackCount:nativeCalls}); + queueMicrotask(()=>void stop().catch(()=>fail('consumer_dispose_failed'))); + }}); + const cleanup=()=>stop(true); slots.set(ctx,cleanup); + record('loader',{branch:'v2',scopeVerified:true}); observer.start(); return cleanup; +} +export default {id:'an-test-portable-public-packaged-reader',server,setup}; diff --git a/scripts/release-opencode-reader.py b/scripts/release-opencode-reader.py new file mode 100644 index 000000000..4732135f4 --- /dev/null +++ b/scripts/release-opencode-reader.py @@ -0,0 +1,481 @@ +"""Fresh exact-source stock OpenCode packaged-reader primitive for release CI. + +Offline --build compiles this .mts consumer with immutable release sources and +vendored SDK 0.3.0. --typecheck-only performs no build/native execution. --execute +requires a sealed seven-cell release manifest. Linux uses a private netns; +Windows uses private files, minimal env and a loopback provider, without a +network isolation claim. Every run uses a new TEST project; reports grant only +a finite reader prerequisite. +""" +import argparse,importlib.util,re,zipfile,base64,hashlib,hmac,http.client,json,os,pathlib,platform,posixpath,secrets,shutil,socket,stat,struct,subprocess,sys,tarfile,threading,time,urllib.parse +from http.server import BaseHTTPRequestHandler +P=pathlib.Path;HERE=P(__file__).resolve().parent +REPO=HERE.parent +CANDIDATE='e86149a724f9b96cb5ee193d63dbc5a533c61e7e' +SDK_SHA256='c3d5aaaf6ecc3116b48ab1ae3f0e00b47720f239df9938f0c499d03f2c21a752' +CELLS={(o,a,v) for o,a in [('linux','amd64'),('linux','arm64'),('windows','amd64')] for v in ['1.18.33','2.0.21']}|{('linux','amd64','1.18.34')} +HTTP_DIAGNOSTICS={};CURRENT_STAGE='not_started' +FLAGS={'productionQualified':False,'installedQualified':False,'timePolicyQualified':False,'sourceEpochQualified':False,'finalSpanQualified':False,'platformLifetimeQualified':False,'loadedMappedBytesQualified':False,'fullNativeQualified':False,'clockQualified':False,'visibleDesktopQualified':False} +def need(v,c): + if not v:raise ValueError(c) +def blob(name): + return subprocess.check_output(['git','show',CANDIDATE+':'+name],cwd=REPO) +def candidate_import(name,specifier): + # Git and JS module specifiers use POSIX logical paths even on Windows. + need('\\' not in name and '\\' not in specifier,'POSIX_candidate_import_required') + child=posixpath.normpath(str(pathlib.PurePosixPath(name).parent/specifier)) + need(child.startswith('opencode-plugin/') and '..' not in pathlib.PurePosixPath(child).parts,'candidate_import_escape') + return child +def esbuild_command_for_magic(executable,node,magic): + # npm postinstall may replace bin/esbuild with the native executable. + # A native ELF/PE must never be passed to Node as JavaScript. + return [str(executable)] if magic.startswith((b'\x7fELF',b'MZ')) else [str(node),str(executable)] +def esbuild_argv(executable,node): + with executable.open('rb') as stream:magic=stream.read(4) + return esbuild_command_for_magic(executable,node,magic) +def candidate_checkout_identity(head,top,dirty,untracked): + need(head==CANDIDATE,'exact_candidate_checkout_HEAD_required') + need(top==str(REPO),'exact_candidate_checkout_root_required') + need(not dirty.strip(),'clean_tracked_candidate_checkout_required') + need(not untracked.strip(),'no_untracked_candidate_source_required') +def verify_candidate_checkout(): + def git(*argv):return subprocess.check_output(['git',*argv],cwd=REPO,text=True).strip() + candidate_checkout_identity(git('rev-parse','HEAD'),str(P(git('rev-parse','--show-toplevel')).resolve(strict=True)), + git('status','--porcelain','--untracked-files=no'), + git('ls-files','--others','--exclude-standard','--','.',':(exclude).task-tools/artifacts')) +def unique(items): + out={} + for key,value in items: + need(key not in out,'duplicate_JSON_key');out[key]=value + return out +def record_bytes(path,body,origin): + return {'path':path,'sha256':hashlib.sha256(body).hexdigest(),'bytes':len(body),'origin':origin} +def artifacts_path(path): + path=path.absolute();need(not any(p.is_symlink() for p in [path,*path.parents]),'artifact_symlink') + need(path.resolve().is_relative_to(REPO/'.task-tools/artifacts'),'reader_artifact_path_required');return path +def network_guard(a): + need(not any(os.environ.get(k) for k in ['GH_TOKEN','GITHUB_TOKEN','OPENAI_API_KEY','ANTHROPIC_API_KEY']),'credentials_forbidden') + if a.os=='linux': + host=os.environ.get('AN_HOST_NETNS','');current=os.readlink('/proc/self/ns/net') + need(re.fullmatch(r'net:\[[1-9][0-9]*\]',host) and current!=host and {n for _,n in socket.if_nameindex()}=={'lo'},'fresh_loopback_only_network_namespace_required') + need(os.geteuid()!=0,'unprivileged_native_execution_required') + return {'host':host,'private':current,'interfaces':['lo']} + else: + # Windows uses the original finite fixture contract: owned private files, + # minimal environment and only a configured loopback completion provider. + # No blanket firewall or external-network isolation is asserted or granted. + return {'platform':'windows','networkIsolationClaimed':False, + 'isolation':'private_files_minimal_env_loopback_provider_no_network_isolation_claim'} +def manifest_inputs(a): + need(sha(a.manifest)==a.manifest_sha256,'same_original_manifest_SHA') + m=json.loads(a.manifest.read_text(),object_pairs_hook=unique) + need(m['candidateCommit']==m['buildRevision']==CANDIDATE and m.get('releaseScope')=='linux-windows','immutable_seven_cell_release_required') + need({(c['os'],c['arch'],c['version']) for c in m['cells']}==CELLS and len(m['cells'])==7,'seven_unique_cells_required') + def checked(item): + need(set(item)=={'path','sha256'},'sealed_record_schema');path=(REPO/item['path']).absolute() + need(not any(p.is_symlink() for p in [path,*path.parents]) and path.resolve().is_relative_to(REPO) and sha(path)==item['sha256'],'sealed_record_identity');return path + for key in ['embedded','rebuilt','packageLock','sdkSource','qualificationSource']:checked(m['assets'][key]) + for key,path in [('embedded','internal/opencodeplugin/dist/agent-notifications.js'),('packageLock','opencode-plugin/package-lock.json')]: + need(m['assets'][key]['sha256']==hashlib.sha256(blob(path)).hexdigest(),'original_candidate_asset_required') + need(m['assets']['embedded']['sha256']==m['assets']['rebuilt']['sha256'],'original_rebuilt_embedded_identity') + need(sha(checked(m['sdk']['archive']))==SDK_SHA256,'exact_vendored_SDK_required') + pins=json.loads(blob('scripts/testdata/opencode-native-e2e/host-pins.json'))['cells'] + cell=next(c for c in m['cells'] if (c['os'],c['arch'],c['version'])==(a.os,a.arch,a.version)) + pin=next(c for c in pins if (c['os'],c['arch'],c['version'])==(a.os,a.arch,a.version)) + need(cell['archive']['sha256']==pin['archiveSHA256'] and cell['archiveSRI']==pin['archiveSRI'] and cell['executableSHA256']==pin['executableSHA256'],'official_cell_archive_custody') + archive=checked(cell['archive']);binary=checked(cell['candidate']) + expected=REPO/'dist'/('claude-notifications-'+a.os+'-'+a.arch+('.exe' if a.os=='windows' else '')) + need(binary==expected,'normal_original_release_binary_required') + custody={'candidateCommit':CANDIDATE,'embeddedSHA256':m['assets']['embedded']['sha256'],'candidateSHA256':sha(binary),'manifestSHA256':a.manifest_sha256,'sdkArchiveSHA256':SDK_SHA256,'hostArchiveSHA256':sha(archive),'hostSourceCommit':cell['hostSourceCommit']} + return custody,pin,archive +def prepare_build(root): + root=artifacts_path(root);need(root.name.startswith('TEST-') and not root.exists(),'fresh_build_root_required');root.mkdir(mode=0o700,parents=True) + leaves=[];source_paths=set() + def candidate(name): + need(name.startswith('opencode-plugin/') and '..' not in P(name).parts,'candidate_import_path');body=blob(name) + need(sha(REPO/name)==hashlib.sha256(body).hexdigest(),'candidate_source_modified_'+name) + target=root/'candidate'/name;target.parent.mkdir(parents=True,exist_ok=True);target.write_bytes(body) + source_paths.add(name);leaves.append(record_bytes('candidate/'+name,body,'candidate:'+CANDIDATE)) + if name.endswith('.mjs'): + for value in re.findall(r'(?:from\s*|import\s*)[\'\"](\.[^\'\"]+)[\'\"]',body.decode()): + child=candidate_import(name,value) + if child not in source_paths:candidate(child) + for name in ['opencode-plugin/native-v1.mjs','opencode-plugin/native-v2.mjs','opencode-plugin/protocol.mjs']:candidate(name) + sdk_name='opencode-plugin/vendor/universal-agent-plugins-opencode-events-0.3.0.tgz';sdk=blob(sdk_name) + need(hashlib.sha256(sdk).hexdigest()==SDK_SHA256 and sha(REPO/sdk_name)==SDK_SHA256,'exact_original_vendored_SDK') + (root/'sdk.tgz').write_bytes(sdk);leaves.append(record_bytes('sdk.tgz',sdk,'candidate:'+CANDIDATE)) + package=root/'node_modules/universal-agent-plugins-opencode-events';package.mkdir(parents=True) + with tarfile.open(root/'sdk.tgz','r:gz') as packed: + members=packed.getmembers();need(00,'original_deadline');return n +class DeadlineSocket(socket.socket): + def recv(self,*a,**k):self.settimeout(remaining(self.end));return super().recv(*a,**k) + def recv_into(self,*a,**k):self.settimeout(remaining(self.end));return super().recv_into(*a,**k) +def request(port,project,path,headers,v2,payload=None,method=None,timeout=2,startup=False): + receipt=HTTP_DIAGNOSTICS.setdefault(CURRENT_STAGE,{'attempts':0,'lastStatus':None});receipt['attempts']+=1;receipt['lastStatus']=None + end=time.monotonic()+timeout;raw=socket.create_connection(('127.0.0.1',port),timeout=remaining(end)) + sock=DeadlineSocket(fileno=raw.detach());sock.end=end;remaining(end) + conn=http.client.HTTPConnection('127.0.0.1',port,timeout=remaining(end));conn.sock=sock + path+=('&' if '?' in path else '?')+urllib.parse.urlencode({'location[directory]' if v2 else 'directory':str(project)}) + try: + body=json.dumps(payload).encode() if payload is not None else None + sock.settimeout(remaining(end));conn.request(method or ('POST' if body is not None else 'GET'),path,body,{'Content-Type':'application/json',**headers}) + sock.settimeout(remaining(end));resp=conn.getresponse();receipt['lastStatus']=resp.status;data=resp.read(1048577);remaining(end) + if startup and resp.status==503:raise ConnectionError('owned_native_starting_503') + need(200<=resp.status<300 and len(data)<=1048576,'native_HTTP_response') + value=json.loads(data) if data else None;return value.get('data',value) if isinstance(value,dict) else value + finally:conn.close() +class Provider(__import__('http.server',fromlist=['HTTPServer']).HTTPServer): + def __init__(self,root,key): + self.lock=threading.Lock();self.http_attempts=0;self.records=[];self.gaps=[] + super().__init__(('127.0.0.1',0),CountedProvider) + def get_request(self): + sock,address=super().get_request();sock.settimeout(4);return sock,address +class CountedProvider(BaseHTTPRequestHandler): + def log_message(self,*args):pass + def denied(self): + with self.server.lock:self.server.http_attempts+=1 + self.send_error(403) + def do_POST(self): + with self.server.lock:self.server.http_attempts+=1;n=self.server.http_attempts + if self.server.mode!='one-completion' or n!=1 or self.path!='/v1/chat/completions':self.send_error(403);return + try: + size=int(self.headers.get('Content-Length','0'));need(0>16)&0o170000!=0o120000 if zipped else m.isfile()),'bounded_regular_image') + with (packed.open(m) if zipped else packed.extractfile(m)) as source,target.open('xb') as out: + left=size + while left: + block=source.read(min(left,1048576));need(block and len(block)<=left,'exact_image_read');out.write(block);left-=len(block) + need(source.read(1)==b'','exact_image_EOF') + need(sha(target)==pin['executableSHA256'],'official_image_SHA') + with target.open('rb') as image: + data=image.read(64) + if os_name=='linux':need(data[:4]==b'\x7fELF' and struct.unpack('0 for value in [u['time']['created'],a['time']['created'],a['time']['completed']]),'ordinary_final_native_assistant') + need(not any(p.get('type')=='tool' for x in history for p in x.get('parts',[])),'zero_native_tools') + p=fact['provenance'];need(p['generation']=='v1' and p['timeBasis']=='assistant_completed' and p['nativeTime']==a['time']['completed'],'original_native_time_no_restamp') + need(any(r['kind']=='native-v1' and r['value'].get('type')=='message.updated' and r['value'].get('properties',{}).get('info',{}).get('id')==H(a['id']) and r['value']['properties']['info'].get('time',{}).get('completed')==a['time']['completed'] for r in rows),'independent_final_callback') + return {'sessionID':H(sid),'userID':H(u['id']),'assistantID':H(a['id']),'userCreated':u['time']['created'],'assistantCreated':a['time']['created'],'assistantCompleted':a['time']['completed'],'finish':a['finish']} +def execute(a): + global CURRENT_STAGE + HTTP_DIAGNOSTICS.clear() + def stage(value): + global CURRENT_STAGE + CURRENT_STAGE=value + actual_os={'Linux':'linux','Darwin':'darwin','Windows':'windows'}[platform.system()];actual_arch={'x86_64':'amd64','AMD64':'amd64','arm64':'arm64','aarch64':'arm64'}[platform.machine()] + need((a.os,a.arch)==(actual_os,actual_arch),'native_runner_cell');need((a.os,a.arch,a.version) in CELLS and a.mode in ['api-only','one-completion'],'closed_scope') + need(a.mode==('api-only' if a.version=='2.0.21' else 'one-completion'),'exact_provider_budget_for_generation') + need(os.environ.get('GITHUB_ACTIONS')=='true' and os.environ.get('SOURCE_COMMIT')==CANDIDATE,'explicit_immutable_release_CI_execution_only') + namespace=network_guard(a) + verify_candidate_checkout() + root=artifacts_path(a.root);need(root.name.startswith('TEST-') and not root.exists() and not any(x.is_symlink() for x in root.parents),'fresh_TEST_root') + custody,pin,archive=manifest_inputs(a);a.archive=archive + build=json.loads(a.build_receipt.read_text());need(sha(a.bundle)==a.bundle_sha256 and sha(a.build_receipt)==a.build_receipt_sha256,'actual_fresh_bundle_build_custody') + validate_build(build,a.bundle) + fixed={str(x):sha(x) for x in [a.archive,a.bundle,a.build_receipt,a.manifest,P(__file__),HERE/'release-opencode-reader.mts']} + original=json.loads(a.manifest.read_text(),object_pairs_hook=unique) + for item in [*original['assets'].values(),original['sdk']['archive'],* [c['candidate'] for c in original['cells'] if (c['os'],c['arch'])==(a.os,a.arch)]]: + path=REPO/item['path'];fixed[str(path)]=sha(path) + for leaf in build['leaves']: + if leaf['path'].startswith('candidate/'): + path=REPO/leaf['path'][len('candidate/'):];fixed[str(path)]=sha(path) + os.umask(0o077);root.mkdir(mode=0o700) + if a.os=='windows':private_windows(root) + project=root/'project';project.mkdir(mode=0o700);host=unpack(a.archive,root,pin,a.os,a.arch);e=env(root) + if a.version!='2.0.21':e.update(NPM_CONFIG_OFFLINE='true',NPM_CONFIG_FETCH_RETRIES='0',OPENCODE_DISABLE_MODELS_FETCH='1') + key=secrets.token_bytes(32);(root/'trace-key').write_bytes(key) + write(root/'.owned-test-root.json',{'purpose':'TEST portable packaged SDK reader'}) + write(root/'reader-authority-private.json',{'version':a.version,'os':a.os,'arch':a.arch,'official':pin,'parentPID':os.getpid(),'directory':str(project.resolve()),'imageSHA256':pin['executableSHA256'],'apiOnlyReadiness':True,'mode':a.mode}) + provider=Provider(root,key);provider.mode=a.mode;provider.http_attempts=0;provider.RequestHandlerClass=CountedProvider + worker=threading.Thread(target=provider.serve_forever);worker.start();proc=None;log=None;headers={};v2=a.version=='2.0.21';success=False + result={'status':'unqualified','cell':{'os':a.os,'arch':a.arch,'version':a.version},'mode':a.mode,'schema':1,'purpose':'TEST fresh stock OpenCode packaged reader primitive',**custody,'plannedProviderTransactions':1 if a.mode=='one-completion' else 0,'sourceGraph':build,'nativeNetworkBoundary':namespace,'freshTestRootCreated':True,'packagedReaderTransportObserved':False,'factoryFinalizationObserved':False,'sourceDescriptorGranted':False,**FLAGS} + stage('configured_loader_preparation') + try: + version=subprocess.check_output([str(host),'--version'],cwd=project,env=e,timeout=8,text=True).strip();need(version in [a.version,'opencode v'+a.version],'exact_installed_stock_runtime_version');result['runtimeVersionOutput']=version + plug=root/'reader.js';shutil.copyfile(a.bundle,plug) + models={'p0-completion':{'name':'TEST completion','limit':{'context':128000,'output':8192}}};endpoint=f'http://127.0.0.1:{provider.server_port}/v1' + config={'model':'p0/p0-completion'} + if v2: + entry=root/'plugin-entry';entry.mkdir(mode=0o700);(entry/'server.js').write_text('export { default } from '+json.dumps(plug.resolve().as_uri())+';\n') + config.update(update='disable',share='disabled',warming=False,formatter=False,lsp=False,websearch=False,plugins=[{'package':str(entry.resolve())}],permissions=[{'action':'*','resource':'*','effect':'deny'}],providers={'p0':{'name':'TEST loopback','package':'@opencode/ai/providers/openai-compatible','env':[],'settings':{'baseURL':endpoint,'apiKey':'sandbox-only','timeout':10000},'models':models}}) + password=secrets.token_hex(16);e['OPENCODE_PASSWORD']=password;headers={'Authorization':'Basic '+base64.b64encode(('opencode:'+password).encode()).decode()} + else:config.update(plugin=[[plug.resolve().as_uri(),{}]],permission={'*':'deny'},provider={'p0':{'npm':'@ai-sdk/openai-compatible','name':'TEST loopback','options':{'baseURL':endpoint,'apiKey':'sandbox-only'},'models':models}}) + write(project/'opencode.json',config);e['OPENCODE_CONFIG']=str(project/'opencode.json') + with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1] + stage('owned_host_spawn') + log=(root/'host-private.log').open('xb');proc=subprocess.Popen([str(host),'serve','--hostname','127.0.0.1','--port',str(port)],cwd=project,env=e,stdin=subprocess.DEVNULL,stdout=log,stderr=subprocess.STDOUT,start_new_session=(a.os!='windows')) + stage('owned_host_health');startup=time.monotonic()+35 + while True: + need(proc.poll() is None,'host_startup_closed');remaining(startup) + try: + info=request(port,project,'/api/info' if v2 else '/global/health',headers,v2,timeout=min(1,remaining(startup)),startup=True);need(info['version']==a.version and (info.get('pid')==proc.pid if v2 else info.get('healthy') is True),'actual_host_identity');result['installedRuntimeVersion']=info['version'];break + except (ConnectionError,TimeoutError,OSError):time.sleep(min(.05,remaining(startup))) + stage('native_project_activation') + request(port,project,'/api/plugin' if v2 else '/config',headers,v2) + stage('native_reader_readiness') + ready=wait_file(root/'reader-ready.json',time.monotonic()+6,proc);need(ready['version']==a.version and ready['nativePID']==proc.pid and ready['parentPID']==os.getpid() and P(ready['actualExecPath']).resolve()==host.resolve() and ready['ownedImageSHA256']==pin['executableSHA256'],'actual_native_loader_image_scope') + if v2:need(ready['firstReaderClosureObserved'] is True and ready['actualAbortRequested'] is True,'actual_first_reader_abort_ready_witness') + result['readerReady']=ready + if not v2: + stage('native_owned_session_create') + sid=request(port,project,'/session',headers,False,{'title':'TEST reader owned'})['id'];enc=urllib.parse.quote(sid,safe='') + if a.mode=='api-only': + stage('native_empty_session_callbacks');request(port,project,'/session/'+enc,headers,False,{'title':'TEST empty reader'},'PATCH');request(port,project,'/session/'+enc,headers,False,method='DELETE') + else: + stage('native_one_completion');request(port,project,'/session/'+enc+'/message',headers,False,{'model':{'providerID':'p0','modelID':'p0-completion'},'parts':[{'type':'text','text':'Reply with one short TEST completion.'}]},timeout=35) + stage('native_reader_disposal') + closed=wait_file(root/'reader-closed.json',time.monotonic()+6,proc);need(closed['actualSDKDispose'] and closed['ownedImageSHA256']==pin['executableSHA256'],'actual_factory_dispose') + result['readerClosed']=closed + stage('independent_reader_oracle') + rows=[json.loads(x) for x in (root/'reader-private.jsonl').read_text().splitlines()];need(len(rows)<=512 and (root/'reader-private.jsonl').stat().st_size<=1048576 and not any(x['kind']=='uncertainty' for x in rows),'bounded_certain_real_ingress') + result['observations']=rows + if v2: + need(closed['actualSDKDone'] and closed['subscriptions']==closed['actualNativeReaderClosures']==closed['registrations']==closed['actualRegistrationDisposals']==2 and closed['markerReads']==2 and closed['sdkFacts']==0,'two_actual_readers_RPC_markers_and_closures') + need(closed['actualAbortRequested'] is True,'actual_planned_abort_closed_witness') + closes=[(i,x['value']) for i,x in enumerate(rows) if x['kind']=='native-reader-close'];aborts=[(i,x['value']) for i,x in enumerate(rows) if x['kind']=='requested-native-abort'] + need(len(closes)==2 and [x['number'] for _,x in closes]==[1,2] and closes[0][1]['actualSignalAborted'] is True and len(aborts)==1 and aborts[0][1]['number']==1 and aborts[0][0]0 and sum(x['kind']=='sdk-observe-invoked' for x in rows)==closed['nativeCalls'],'real_callback_observe_drain') + if a.mode=='api-only': + H=lambda x:'h:'+hmac.new(key,str(x).encode(),hashlib.sha256).hexdigest()[:24] + matching=[x['value'] for x in rows if x['kind']=='native-v1' and x['value'].get('properties',{}).get('info',{}).get('id')==H(sid)] + need({'session.created','session.updated','session.deleted'}<={x['type'] for x in matching} and closed['sdkFacts']==0,'actual_empty_session_callback_membership') + else: + stage('independent_native_history');result['nativeV1Identity']=independent_v1_fact(root,key,sid,request(port,project,'/session/'+enc+'/message',headers,False));result['factoryFinalizationObserved']=True + need(provider.http_attempts==(1 if a.mode=='one-completion' else 0) and len(provider.records)==provider.http_attempts and not provider.gaps,'explicit_exact_provider_budget_no_retry') + result.update(packagedReaderTransportObserved=True,ownedImageSHA256=pin['executableSHA256'],bundleSHA256=a.bundle_sha256,traceSHA256=sha(root/'reader-private.jsonl'),closedReceiptSHA256=sha(root/'reader-closed.json'));success=True + except Exception as error: + result['firstFailedStage']=CURRENT_STAGE;result['exceptionKind']=type(error).__name__;result['failure']=type(error).__name__ + write(root/'first-exception-private.json',{'stage':CURRENT_STAGE,'kind':type(error).__name__,'message':str(error)[:512]}) + finally: + if proc is not None and proc.poll() is None: + proc.terminate();result['ownedHostStopRequested']=True + try:proc.wait(timeout=6) + except subprocess.TimeoutExpired:proc.kill();proc.wait(timeout=2);result['hostForcedKillUsed']=True;success=False + result['ownedLeaderWaitObserved']=proc is not None and proc.poll() is not None;result['hostProcessTreeClosure']='unproved_portable_leader_only' + if log is not None: + log.close();hp=root/'host-private.log';size=hp.stat().st_size;result['hostPrivateLogSHA256']=sha(hp);result['hostPrivateLogBytes']=size + with hp.open('rb') as source,(root/'host-startup-prefix-private.log').open('xb') as dest:dest.write(source.read(65536)) + result['hostStartupPrefixBytes']=min(size,65536);result['hostStartupPrefixTruncated']=size>65536 + if size>1048576:result['hostPrivateLogOverflow']=True;success=False + result['ownHTTPDiagnostics']={k:dict(v) for k,v in HTTP_DIAGNOSTICS.items()} + provider.shutdown();provider.server_close();worker.join(timeout=3);result['providerThreadJoined']=not worker.is_alive() + result['actualProviderHTTPRequestAttempts']=provider.http_attempts;result['actualProviderTransactions']=len(provider.records);result['providerReceipts']=provider.records;result['actualToolCalls']=0 if not provider.records or all(x.get('requestKind')=='ordinary' for x in provider.records) else None + result['sourcesArchiveBundleUnchanged']=all(sha(P(n))==h for n,h in fixed.items()) and sha(host)==pin['executableSHA256'] + try:verify_candidate_checkout() + except Exception:result['sourcesArchiveBundleUnchanged']=False + if not result['providerThreadJoined'] or not result['sourcesArchiveBundleUnchanged'] or provider.http_attempts!=(1 if a.mode=='one-completion' else 0):success=False + result['status']='release_packaged_reader_observed' if success else 'unqualified';write(root/'result.json',result);write(artifacts_path(a.report),result) + print(json.dumps({'status':result['status'],'resultSHA256':sha(root/'result.json'),'cell':result['cell'],'actualProviderTransactions':result['actualProviderTransactions'],'qualificationGranted':False}));return 0 if success else 1 + +def self_test(): + need(candidate_import('opencode-plugin/subdir/view.mjs','../protocol.mjs')=='opencode-plugin/protocol.mjs','portable_POSIX_import_normalization') + need(candidate_import('opencode-plugin/native-v2.mjs','./protocol.mjs')=='opencode-plugin/protocol.mjs','portable_same_directory_import') + for specifier in ['../../outside.mjs','..\\outside.mjs','/outside.mjs']: + try:candidate_import('opencode-plugin/native-v2.mjs',specifier) + except ValueError:continue + raise AssertionError('nonportable_or_escaping_import_accepted') + for magic in [b'\x7fELF',b'MZ\x90\0']: + need(esbuild_command_for_magic('esbuild','node',magic)==['esbuild'],'native_esbuild_must_execute_directly') + need(esbuild_command_for_magic('esbuild','node',b'#!/u')==['node','esbuild'],'JS_esbuild_must_execute_with_Node') + candidate_checkout_identity(CANDIDATE,str(REPO),'','') + for values in [('0'*40,str(REPO),'',''),(CANDIDATE,str(REPO/'other'),'',''), + (CANDIDATE,str(REPO),' M opencode-plugin/native-v1.mjs',''), + (CANDIDATE,str(REPO),'','unexpected-source.mts')]: + try:candidate_checkout_identity(*values) + except ValueError:continue + raise AssertionError('wrong_or_modified_candidate_checkout_accepted') + # Reject counterfeit RPC counts without the independent native callback, + # or with missing original identities/timestamps; no host is ever launched. + registers=[{'kind':'rpc-register','value':{'number':i,'type':'rpc.test'+str(i)+'.checkpoint'}} for i in [1,2]] + rows=registers[:] + for i in [1,2]: + envelope={'id':'h:'+str(i),'created':100+i,'type':'rpc.test'+str(i)+'.checkpoint','data':{'nonce':'h:nonce'+str(i)}} + rows.extend([{'kind':'native-v2','value':envelope},{'kind':'rpc-marker-consumed','value':{'number':i,'markerReads':i,'envelope':envelope}}]) + validate_v2_rows(rows,{}) + for alteration in ['missing_callback','missing_identity','restamped_time','duplicate_marker','callback_after_marker']: + bad=json.loads(json.dumps(rows)) + if alteration=='missing_callback':bad=[r for r in bad if r['kind']!='native-v2'] + elif alteration=='missing_identity':bad[-1]['value']['envelope'].pop('id') + elif alteration=='restamped_time':bad[-1]['value']['envelope']['created']+=1 + elif alteration=='duplicate_marker':bad[-1]['value']['envelope']=bad[-3]['value']['envelope'] + else:bad[-2],bad[-1]=bad[-1],bad[-2] + try:validate_v2_rows(bad,{}) + except (ValueError,KeyError):continue + raise AssertionError('counterfeit_native_marker_accepted_'+alteration) + key=b'\0'*32;H=lambda x:'h:'+hmac.new(key,str(x).encode(),hashlib.sha256).hexdigest()[:24] + user={'id':'user-test','sessionID':'session-test','role':'user','time':{'created':100}} + assistant={'id':'assistant-test','sessionID':'session-test','parentID':'user-test','role':'assistant','finish':'stop','time':{'created':101,'completed':102}} + fact={'kind':'turn_idle_verified','rootSession':True,'sessionID':H('session-test'),'turnID':H('user-test'),'messageID':H('assistant-test'),'provenance':{'generation':'v1','timeBasis':'assistant_completed','nativeTime':102}} + callback={'type':'message.updated','properties':{'info':{'id':H('assistant-test'),'time':{'completed':102}}}} + rows=[{'kind':'sdk-fact','value':{'event':fact,'currentAtCallback':True,'clockID':'local-performance'}},{'kind':'native-v1','value':callback}] + history=[{'info':user,'parts':[]},{'info':assistant,'parts':[]}] + validate_v1_rows(rows,key,'session-test',history) + for alteration in ['missing_final_callback','wrong_user_identity','restamped_completion','duplicate_sdk_fact','tool_called']: + bad=json.loads(json.dumps(rows));native_history=json.loads(json.dumps(history)) + if alteration=='missing_final_callback':bad.pop() + elif alteration=='wrong_user_identity':bad[0]['value']['event']['turnID']=H('other-user') + elif alteration=='restamped_completion':bad[0]['value']['event']['provenance']['nativeTime']=103 + elif alteration=='duplicate_sdk_fact':bad.append(bad[0]) + else:native_history[1]['parts'].append({'type':'tool'}) + try:validate_v1_rows(bad,key,'session-test',native_history) + except (ValueError,KeyError):continue + raise AssertionError('counterfeit_native_completion_accepted_'+alteration) + print('PASS V1 original native completion/identity/callback and V2 original RPC marker identity/time/membership') + return 0 + +def main(): + global REPO + p=argparse.ArgumentParser(description=__doc__);m=p.add_mutually_exclusive_group(required=True) + for mode in ['build','execute','typecheck-only','self-test']:m.add_argument('--'+mode,action='store_true') + p.add_argument('--candidate-repo','--source-root',dest='candidate_repo',type=P) + for n in ['build-root','node','tsc','node-types','esbuild','root','manifest','bundle','build-receipt','report']:p.add_argument('--'+n,type=P) + for n in ['manifest-sha256','bundle-sha256','build-receipt-sha256','os','arch','version']:p.add_argument('--'+n) + a=p.parse_args() + try: + if a.self_test:return self_test() + required=['candidate_repo','build_root','tsc','node_types'] if a.build or a.typecheck_only else ['candidate_repo','root','manifest','manifest_sha256','bundle','bundle_sha256','build_receipt','build_receipt_sha256','os','arch','version','report'] + if a.build:required+=['node','esbuild'] + need(all(getattr(a,key) is not None for key in required),'mode_arguments_required') + REPO=a.candidate_repo.resolve(strict=True) + if a.build or a.typecheck_only:return build(a) + need((a.os,a.arch,a.version) in CELLS,'closed_seven_cell_scope') + a.mode='api-only' if a.version=='2.0.21' else 'one-completion' + return execute(a) + except Exception as error: + if a.report is not None: + report=artifacts_path(a.report) + if not report.exists():write(report,{'schema':1,'status':'unqualified','firstFailedPrerequisite':str(error)[:256],'businessPhasesStarted':False,**FLAGS}) + print(json.dumps({'status':'unqualified','failure':str(error)[:256]}),file=sys.stderr);return 1 + +if __name__=='__main__':sys.exit(main()) diff --git a/scripts/release-opencode-recovery.py b/scripts/release-opencode-recovery.py new file mode 100644 index 000000000..b4d8d9a9d --- /dev/null +++ b/scripts/release-opencode-recovery.py @@ -0,0 +1,581 @@ +#!/usr/bin/env python3 +"""Observe business scenarios of immutable release artifacts with fresh primitives. + +The candidate checkout and its fixture stay unchanged. GitHub validation runs +before isolation; no GitHub token is needed or accepted by native execution. +""" +import argparse +import hashlib +import importlib.util +import json +import os +import pathlib +import re +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile + +WINDOWS_CANDIDATE = 'e86149a724f9b96cb5ee193d63dbc5a533c61e7e' +RELEASE_TAG = 'v1.48.1' +# Parent must replace these from the actual new release build and custody. +# Predecessor v1.48.0 pins are intentionally not valid for this candidate. +# Preparation is not native-ready until all four observed values are supplied. +WINDOWS_MANIFEST_SHA = 'a7e4d3603b2958ee58b31a2db6fdb8c7dfb006183474828dbdad8197cb069c30' +WINDOWS_PARENT_SHA = 'ea156e1978d5acad56ff6cfbebc8cb20ba646b55a271e137dc696e6646bc64d9' +WINDOWS_BINARY_SHA = 'a026587157a9a0aa60c4c45ddae089622d3e7f4fd96321e11fa1a3545153c8e2' +WINDOWS_SOURCE_SHA = '671fea533825aa6fe0f55299871dafbbbcd61cb3f2b88ab783c4274d986e8774' +WINDOWS_EMBEDDED_SHA = WINDOWS_SOURCE_SHA +WINDOWS_EMBEDDED_OFFSET = 14257054 +WINDOWS_ACL_HELPER_SHA = 'd0995c320cb3b03a287a43915d2319a1b71f93b1d112c1f60ecc61cb276c15bd' +WINDOWS_ACL_HELPER = 'scripts/opencode-private-root-windows.go' + + +def windows_release_bindings(candidate, manifest, parent, binary): + require(all(isinstance(value, str) and re.fullmatch('[a-f0-9]{64}', value) + for value in (WINDOWS_MANIFEST_SHA, WINDOWS_PARENT_SHA, WINDOWS_BINARY_SHA)) and + type(WINDOWS_EMBEDDED_OFFSET) is int and WINDOWS_EMBEDDED_OFFSET >= 0, + 'new_windows_release_pins_pending_not_native_ready') + require((candidate, manifest, parent, binary) == + (WINDOWS_CANDIDATE, WINDOWS_MANIFEST_SHA, WINDOWS_PARENT_SHA, WINDOWS_BINARY_SHA), + 'original_windows_release_custody_required') + + +def windows_embedded_asset(canonical, image, observed_offset): + require(len(canonical) == 176304 and canonical.count(b'\r') == 0 and + canonical.count(b'\n') == 4206 and hashlib.sha256(canonical).hexdigest() == WINDOWS_SOURCE_SHA, + 'pinned_canonical_lf_asset_required') + require(type(observed_offset) is int and observed_offset >= 0, + 'observed_new_windows_embed_offset_required') + require(image.count(canonical) == 1 and image.find(canonical) == observed_offset and + canonical.replace(b'\n', b'\r\n') not in image, + 'unique_original_canonical_windows_embed_required') + return canonical + + +def windows_registration_oracle(original, receipts): + def registration(root, plugin, managed, canonical_asset): + require(hashlib.sha256(canonical_asset.read_bytes()).hexdigest() == WINDOWS_SOURCE_SHA, + 'canonical_asset_changed_before_registration') + require(re.fullmatch('TEST-installed-[a-f0-9]{24}', root.name), 'owned_TEST_registration_required') + for path in (managed, root / 'control'): + value = str(path) + require(path.is_absolute() and 0 < len(value) <= 512 and + all(32 <= ord(c) <= 126 for c in value) and '"__AGENT_NOTIFICATIONS_' not in value, + 'bounded_ascii_go_quote_paths_required') + ledger = json.loads((root / 'control/ownership.json').read_text()) + actual = ledger['Consumers']['opencode-notifications']['OpenCode'] + require(actual['OriginBound'] is True and all(re.fullmatch('[a-f0-9]{64}', actual[k]) + for k in ('Origin', 'Salt', 'Namespace', 'BundleSHA256')), 'actual_origin_bound_registration_required') + # Recording only: preserve the original fixture and its actual asset + # argument. No EOL proxy, transformed asset or replacement oracle. + result = original(root, plugin, managed, canonical_asset) + require(result == actual, 'registration_changed_during_render_check') + require(len(receipts) < 3, 'extra_registration_observation') + receipts.append({'phase': ('install', 'update', 'reinstall')[len(receipts)], + 'canonicalGitAssetSHA256': WINDOWS_SOURCE_SHA, + 'platformEmbeddedSHA256': WINDOWS_EMBEDDED_SHA, + 'binarySHA256': WINDOWS_BINARY_SHA, 'embeddedOffset': WINDOWS_EMBEDDED_OFFSET, + 'embeddedBytes': 176304, 'transform': 'none', 'lineEndings': 'LF', + 'renderedInstalledSHA256': result['BundleSHA256'], + 'actualLedgerBundleSHA256': result['BundleSHA256'], + 'origin': result['Origin'], 'originBound': True, 'rawInstalledEquality': True}) + return result + + return registration + + +def windows_acl_source_binding(head, git_body, actual_body, dirty): + require(head == WINDOWS_CANDIDATE and not dirty.strip() and + hashlib.sha256(git_body).hexdigest() == WINDOWS_ACL_HELPER_SHA and + actual_body == git_body, 'immutable_windows_acl_helper_required') + + +def windows_acl_cache(value): + require(isinstance(value, str) and value and pathlib.Path(value).is_absolute(), + 'absolute_prefetched_module_cache_required') + path = pathlib.Path(value) + require(not any(p.is_symlink() or (hasattr(p, 'is_junction') and p.is_junction()) + for p in (path, *path.parents)), 'plain_prefetched_module_cache_required') + path = path.resolve(strict=True) + require(path.is_dir(), 'existing_prefetched_module_cache_required') + return path + + +def windows_acl_environment(root, cache, inherited): + # Keep the original disposable private directories and system whitelist. + # Only the trusted prefetched module cache escapes this fresh TEST root. + env = {key: inherited[key] for key in ('PATH', 'SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT') + if key in inherited} + for key in ('HOME', 'USERPROFILE', 'GOCACHE', 'TEMP', 'TMP'): + child = root / ('private-' + key.lower()) + child.mkdir(mode=0o700) + env[key] = str(child) + env.update(GOMODCACHE=str(cache), GOFLAGS='-mod=readonly', GOPROXY='off', GOSUMDB='off', + GOTOOLCHAIN='local', GOENV='off', GOWORK='off') + require(env.get('GOMODCACHE') == str(cache), 'explicit_prefetched_module_cache_required') + return env + + +def bounded_acl_output(body): + body = body or b'' + require(isinstance(body, bytes), 'binary_acl_diagnostics_required') + return {'bytes': len(body), 'sha256': hashlib.sha256(body).hexdigest(), + 'truncated': len(body) > 16384, + 'prefix': body[:16384].decode('utf8', errors='replace')} + + +def windows_acl_preparer(repo, cache_value, receipts, receipt_path): + cache = windows_acl_cache(cache_value) + helper = repo / WINDOWS_ACL_HELPER + + def source_binding(): + head = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo, text=True).strip() + dirty = subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=no'], + cwd=repo, text=True) + body = subprocess.check_output(['git', 'show', WINDOWS_CANDIDATE + ':' + WINDOWS_ACL_HELPER], cwd=repo) + windows_acl_source_binding(head, body, helper.read_bytes(), dirty) + + def prepare(root, os_name): + require(sys.platform == 'win32' and os_name == 'windows', 'actual_windows_acl_preparation_only') + require(re.fullmatch('TEST-installed-[a-f0-9]{24}', root.name) and root.is_dir() and + root.resolve(strict=True).is_relative_to(repo / '.task-tools/artifacts') and + not any(p.is_symlink() or (hasattr(p, 'is_junction') and p.is_junction()) + for p in (root, *root.parents)), 'fresh_owned_windows_acl_root_required') + require(not receipts, 'one_windows_acl_preparation_only') + source_binding() + env = windows_acl_environment(root, cache, os.environ) + record = {'helperPath': WINDOWS_ACL_HELPER, 'helperSHA256': WINDOWS_ACL_HELPER_SHA, + 'candidateCommit': WINDOWS_CANDIDATE, 'prefetchedModuleCache': str(cache), + 'cwd': str(repo), 'privateEnvironmentKeys': sorted(env), 'moduleMode': 'readonly', + 'externalModuleFetchAllowed': False, 'timeoutSeconds': 90, + 'aclReadbackClaimed': False, 'productBinaryRebuilt': False, + 'helperReturncode': None, 'timedOut': False} + try: + result = subprocess.run(['go', 'run', str(helper), str(root)], cwd=repo, env=env, + capture_output=True, timeout=90) + record.update(helperReturncode=result.returncode, + stdout=bounded_acl_output(result.stdout), stderr=bounded_acl_output(result.stderr)) + except subprocess.TimeoutExpired as error: + record.update(timedOut=True, stdout=bounded_acl_output(error.stdout), + stderr=bounded_acl_output(error.stderr)) + # Preserve the genuine command result before any acceptance assertion. + receipts.append(record) + with receipt_path.open('x', encoding='utf8', newline='\n') as output: + json.dump({'schema': 1, 'purpose': 'TEST original Windows ACL helper preparation', + 'receipts': receipts}, output, sort_keys=True, indent=2) + output.write('\n') + receipt_path.chmod(0o600) + source_binding() + require(not record['timedOut'] and record['helperReturncode'] == 0, + 'windows_private_dacl_failed') + + return prepare + + +def require(ok, code): + if not ok: + raise ValueError(code) + + +def bindings(): + run = os.environ.get('AN_RECOVERY_RUN_ID', '') + candidate = os.environ.get('AN_RECOVERY_CANDIDATE_SHA', '') + archive = os.environ.get('AN_EVIDENCE_SHA256', '') + require(re.fullmatch('[1-9][0-9]{0,19}', run), 'numeric_original_run_required') + require(re.fullmatch('[0-9a-f]{40}', candidate), 'exact_candidate_sha_required') + require(candidate == WINDOWS_CANDIDATE, 'exact_v1481_candidate_required') + require(re.fullmatch('[0-9a-f]{64}', archive), 'sealed_archive_sha_required') + return run, candidate, archive + + +def recovery_matrix(scope=None): + scope = os.environ.get('AN_RECOVERY_CELLS', '') if scope is None else scope + require(scope in ('first-linux-amd64-v2', 'windows-two', 'all-seven'), 'explicit_recovery_cells_required') + targets = [ + {'runner': 'ubuntu-22.04', 'platform': 'linux', 'arch': 'amd64', + 'binary': 'claude-notifications-linux-amd64'}, + {'runner': 'ubuntu-22.04-arm', 'platform': 'linux', 'arch': 'arm64', + 'binary': 'claude-notifications-linux-arm64'}, + {'runner': 'windows-latest', 'platform': 'windows', 'arch': 'amd64', + 'binary': 'claude-notifications-windows-amd64.exe'}] + cells = [{'version': version, 'target': target} + for target in targets for version in ('1.18.33', '2.0.21')] + cells.append({'version': '1.18.34', 'target': targets[0]}) + if scope == 'first-linux-amd64-v2': + cells = [cell for cell in cells if cell['target'] == targets[0] and cell['version'] == '2.0.21'] + elif scope == 'windows-two': + cells = [cell for cell in cells if cell['target'] == targets[2]] + return {'include': cells} + + +def namespace_proof(host, current, interfaces): + require(re.fullmatch(r'net:\[[1-9][0-9]*\]', host or '') and + re.fullmatch(r'net:\[[1-9][0-9]*\]', current or ''), 'network_namespace_identity_required') + require(current != host, 'new_private_netns_required') + require(set(interfaces) == {'lo'}, 'loopback_only_required') + return {'host': host, 'private': current, 'interfaces': ['lo']} + + +def self_test(): + # A reused/malformed namespace or any external interface must fail closed. + namespace_proof('net:[10]', 'net:[11]', ['lo']) + cases = [('', 'net:[11]', ['lo']), ('net:[10]', 'bad', ['lo']), + ('net:[10]', 'net:[10]', ['lo']), ('net:[10]', 'net:[11]', []), + ('net:[10]', 'net:[11]', ['lo', 'eth0'])] + for host, current, interfaces in cases: + try: + namespace_proof(host, current, interfaces) + except ValueError: + continue + raise AssertionError('unsafe namespace accepted') + # A vertical canary must not silently run extra cells; all-seven must not + # lose the separately pinned 1.18.34 cell or admit macOS. + first = recovery_matrix('first-linux-amd64-v2')['include'] + require(len(first) == 1 and first[0]['version'] == '2.0.21' and + (first[0]['target']['platform'], first[0]['target']['arch']) == ('linux', 'amd64'), + 'first_recovery_cell_scope') + all_cells = recovery_matrix('all-seven')['include'] + actual = {(c['target']['platform'], c['target']['arch'], c['version']) for c in all_cells} + expected = {(o, a, v) for o, a in (('linux', 'amd64'), ('linux', 'arm64'), ('windows', 'amd64')) + for v in ('1.18.33', '2.0.21')} | {('linux', 'amd64', '1.18.34')} + require(len(all_cells) == 7 and actual == expected, 'all_seven_recovery_cell_scope') + windows = recovery_matrix('windows-two')['include'] + expected_windows = [{'version': version, 'target': { + 'runner': 'windows-latest', 'platform': 'windows', 'arch': 'amd64', + 'binary': 'claude-notifications-windows-amd64.exe'}} for version in ('1.18.33', '2.0.21')] + require(windows == expected_windows, 'exact_two_windows_recovery_cell_scope') + try: + recovery_matrix('all-eleven') + except ValueError: + pass + else: + raise AssertionError('unknown recovery scope accepted') + windows_adapter_self_test() + windows_acl_self_test() + print('PASS fresh namespace, valid identities and loopback-only boundary') + + +def windows_adapter_self_test(): + # Inert byte/ledger fixtures only: no Go binary, installer or native host. + source = pathlib.Path(__file__).resolve().parents[1] / 'internal/opencodeplugin/dist/agent-notifications.js' + canonical = source.read_bytes() + observed_offset = 64 # Synthetic fixture position, never a release pin. + prefix = b'\x00' * observed_offset + image = prefix + canonical + b'\x00' + require(windows_embedded_asset(canonical, image, observed_offset) == canonical, + 'actual_canonical_lf_fixture') + + def rejected(operation): + try: + operation() + except (ValueError, fixture.Unqualified): + return + raise AssertionError('unsafe_windows_render_custody_accepted') + + spec = importlib.util.spec_from_file_location('inert_registration_oracle', source.parents[3] / 'scripts/opencode-native-e2e.py') + fixture = importlib.util.module_from_spec(spec); spec.loader.exec_module(fixture) + crlf = canonical.replace(b'\n', b'\r\n') + for bad_source, bad_image, bad_offset in ((crlf, image, observed_offset), + (canonical + b'x', image, observed_offset), (canonical, prefix + crlf, observed_offset), + (canonical, image + canonical, observed_offset), + (canonical, b'\x00' + image, observed_offset), + (canonical, prefix + b'x' + canonical[1:], observed_offset), + (canonical, image + crlf, observed_offset), (canonical, image, None)): + rejected(lambda: windows_embedded_asset(bad_source, bad_image, bad_offset)) + # Fresh artifact pins are not inferred or copied from the predecessor. + pins = [WINDOWS_CANDIDATE, WINDOWS_MANIFEST_SHA, WINDOWS_PARENT_SHA, WINDOWS_BINARY_SHA] + if any(pin is None for pin in pins) or WINDOWS_EMBEDDED_OFFSET is None: + rejected(lambda: windows_release_bindings(WINDOWS_CANDIDATE, '0' * 64, '0' * 64, '0' * 64)) + else: + windows_release_bindings(*pins) + for index in range(4): + altered_pins = list(pins); altered_pins[index] = '0' * len(pins[index]) + rejected(lambda: windows_release_bindings(*altered_pins)) + with tempfile.TemporaryDirectory(prefix='TEST-recovery-render-') as temp: + root = pathlib.Path(temp) / ('TEST-installed-' + 'a' * 24) + (root / 'control').mkdir(parents=True) + plugin, managed = root / 'installed.js', root / 'runtime/claude-notifications-windows-amd64.exe' + record = dict(Origin='1' * 64, Salt='2' * 64, Namespace='3' * 64, OriginBound=True) + rendered = canonical.decode() + for key, value in {'EXECUTABLE': str(managed), 'CONTROL_ROOT': str(root / 'control'), 'ORIGIN': record['Origin']}.items(): + rendered = rendered.replace('"__AGENT_NOTIFICATIONS_' + key + '__"', json.dumps(value, ensure_ascii=False)) + actual = rendered.encode(); record['BundleSHA256'] = hashlib.sha256(actual).hexdigest() + ledger = root / 'control/ownership.json' + + def stage(registration, installed): + ledger.write_text(json.dumps({'Consumers': {'opencode-notifications': {'OpenCode': registration}}})) + plugin.write_bytes(installed) + + def original_with_actual_asset(root, plugin, managed, asset): + require(asset is source, 'original_fixture_actual_asset_argument_required') + return fixture.registration(root, plugin, managed, asset) + + receipts = []; oracle = windows_registration_oracle(original_with_actual_asset, receipts) + stage(record, actual) + require(oracle(root, plugin, managed, source) == record and len(receipts) == 1, + 'unchanged_canonical_lf_registration_oracle') + wrong_newlines = actual.replace(b'\n', b'\r\n') + for altered, installed in ((dict(record, Origin='f' * 64), actual), + (dict(record, Origin='bad'), actual), + (dict(record, BundleSHA256='0' * 64), actual), + (dict(record, BundleSHA256=hashlib.sha256(wrong_newlines).hexdigest()), wrong_newlines), + (dict(record, BundleSHA256=hashlib.sha256(actual + b'x').hexdigest()), actual + b'x')): + stage(altered, installed) + rejected(lambda: oracle(root, plugin, managed, source)) + require(len(receipts) == 1, 'original_registration_failure_must_not_emit_receipt') + stage(record, actual) + oracle(root, plugin, managed, source); oracle(root, plugin, managed, source) + require([r['phase'] for r in receipts] == ['install', 'update', 'reinstall'] and + all(r['transform'] == 'none' and r['lineEndings'] == 'LF' for r in receipts), + 'three_untransformed_original_registration_receipts') + rejected(lambda: oracle(root, plugin, managed, source)) + print('PASS canonical LF unique embedding, no stale pins and unchanged original registration failures') + + +def windows_acl_self_test(): + # No Windows helper or Go compiler execution. These assertions turn red if + # the external cache is omitted, credentials leak or source pins are relaxed. + source = pathlib.Path(__file__).resolve().parents[1] / WINDOWS_ACL_HELPER + body = source.read_bytes() + windows_acl_source_binding(WINDOWS_CANDIDATE, body, body, '') + for values in [('0' * 40, body, body, ''), (WINDOWS_CANDIDATE, body + b'x', body, ''), + (WINDOWS_CANDIDATE, body, body + b'x', ''), + (WINDOWS_CANDIDATE, body, body, ' M scripts/opencode-private-root-windows.go')]: + try: + windows_acl_source_binding(*values) + except ValueError: + continue + raise AssertionError('changed_acl_source_accepted') + with tempfile.TemporaryDirectory(prefix='TEST-recovery-acl-env-') as temporary: + base = pathlib.Path(temporary).resolve() + cache = base / 'prefetched-modules'; cache.mkdir() + root = base / 'TEST-private-environment'; root.mkdir() + env = windows_acl_environment(root, windows_acl_cache(str(cache)), + {'PATH': 'system-tools', 'SystemRoot': 'system-root', + 'HOME': 'ambient-home', 'GH_TOKEN': 'TEST-auth-sentinel', + 'OPENAI_API_KEY': 'TEST-auth-sentinel'}) + require(env['GOMODCACHE'] == str(cache) and pathlib.Path(env['GOCACHE']).is_relative_to(root) and + env['HOME'] != 'ambient-home' and 'TEST-auth-sentinel' not in env.values(), + 'private_acl_env_explicit_external_cache_no_auth') + require(all(env[k] == v for k, v in {'GOFLAGS': '-mod=readonly', 'GOPROXY': 'off', + 'GOSUMDB': 'off', 'GOTOOLCHAIN': 'local', 'GOENV': 'off', 'GOWORK': 'off'}.items()), + 'offline_readonly_acl_go_contract') + file = base / 'not-a-cache'; file.write_bytes(b'TEST') + for value in ['', 'relative/cache', str(file)]: + try: + windows_acl_cache(value) + except ValueError: + continue + raise AssertionError('invalid_module_cache_accepted') + output = bounded_acl_output(b'x' * 16385) + require(len(output['prefix']) == 16384 and output['bytes'] == 16385 and output['truncated'] and + output['sha256'] == hashlib.sha256(b'x' * 16385).hexdigest(), 'bounded_acl_diagnostic_receipt') + print('PASS immutable ACL source, explicit offline module cache, private env and bounded diagnostics') + + +def github_json(endpoint, paginate=False): + argv = ['gh', 'api', endpoint] + if paginate: + argv += ['--paginate', '--slurp'] + return json.loads(subprocess.check_output(argv, text=True)) + + +def verify_origin(): + run, candidate, archive = bindings() + matrix = recovery_matrix() + repository = os.environ.get('GITHUB_REPOSITORY', '') + require(repository == '777genius/agent-notifications', 'release_repository_required') + base = f'repos/{repository}/actions/runs/{run}' + origin = github_json(base) + require(str(origin['id']) == run and origin['head_sha'] == candidate and + origin['path'] == '.github/workflows/release.yml' and + origin['event'] == 'push' and origin['head_branch'] == RELEASE_TAG and + origin['status'] == 'completed' and origin['conclusion'] in ('success', 'failure'), + 'immutable_completed_release_run_required') + jobs = [job for page in github_json(base + '/jobs?filter=latest&per_page=100', True) + for job in page['jobs']] + expected = ['Seal same-run OpenCode release inputs'] + for platform, arch in (('linux', 'amd64'), ('linux', 'arm64'), ('windows', 'amd64')): + expected += [f'Build {platform} {arch}', f'Test {platform} {arch} binary'] + for name in expected: + matches = [job for job in jobs if job['name'] == name] + require(len(matches) == 1 and matches[0]['status'] == 'completed' and + matches[0]['conclusion'] == 'success', 'original_build_canary_and_seal_required') + artifacts = [item for page in github_json(base + '/artifacts?per_page=100', True) + for item in page['artifacts']] + for name in ('binaries-linux-amd64', 'binaries-linux-arm64', 'binaries-windows-amd64', + 'release-opencode-inputs-' + candidate): + matches = [item for item in artifacts if item['name'] == name] + require(len(matches) == 1 and not matches[0]['expired'], 'original_artifact_required') + with pathlib.Path(os.environ['GITHUB_OUTPUT']).open('a') as output: + output.write(f'run_id={run}\ncandidate_sha={candidate}\nevidence_sha256={archive}\n') + output.write('matrix=' + json.dumps(matrix, separators=(',', ':')) + '\n') + print('Verified original release SHA, three builds/canaries and sealed artifact custody') + + +def external_helper(name): + return pathlib.Path(__file__).resolve().with_name(name) + + +def prepare_primitives(repo): + """Only offline witness compilation; no native host or production rebuild.""" + primitives = repo / '.task-tools/artifacts/primitives' + primitives.mkdir(mode=0o700) + node = shutil.which('node') + require(node is not None, 'pinned_node_required') + modules = repo / 'opencode-plugin/node_modules' + compiler = modules / '.bin' / ('tsc.cmd' if sys.platform == 'win32' else 'tsc') + subprocess.run([sys.executable, '-B', str(external_helper('release-opencode-reader.py')), + '--build', '--candidate-repo', str(repo), '--build-root', str(primitives / 'TEST-reader-build'), + '--node', node, '--tsc', str(compiler), '--node-types', str(modules / '@types'), + '--esbuild', str(modules / 'esbuild/bin/esbuild')], check=True, timeout=180) + cache = pathlib.Path(subprocess.check_output(['go', 'env', 'GOMODCACHE'], text=True).strip()).resolve(strict=True) + if sys.platform == 'win32': + clock_base = pathlib.Path('C:/TEST-clock-' + secrets.token_hex(6)) + clock_base.mkdir(mode=0o700) + else: + clock_base = pathlib.Path(tempfile.mkdtemp(prefix='TEST-release-clock-', dir='/tmp')) + require(not clock_base.resolve().is_relative_to(repo), 'external_clock_test_root_required') + with pathlib.Path(os.environ['GITHUB_OUTPUT']).open('a') as output: + output.write(f'clock_temp_base={clock_base}\ngo_module_cache={cache}\n') + + +def fresh_business_proof(repo, manifest, manifest_sha, run, parent_sha, cell, r): + primitives = repo / '.task-tools/artifacts/primitives' + clock_report, reader_report = primitives / 'clock-report.json', primitives / 'reader-report.json' + platform_args = ['--os', cell[0], '--arch', cell[1], '--version', cell[2]] + subprocess.run([sys.executable, '-B', str(external_helper('release-opencode-clock.py')), + '--execute-ci', '--candidate-repo', str(repo), + '--temp-base', os.environ['AN_CLOCK_TEMP_BASE'], '--report', str(clock_report), + '--manifest', str(manifest), '--manifest-sha256', manifest_sha, + '--parent-archive', str(repo / '.task-tools/artifacts/custody/release-opencode-inputs.tar.gz'), + '--parent-archive-sha256', parent_sha, '--original-run-id', run, + '--go-module-cache', os.environ['AN_GO_MODULE_CACHE'], *platform_args], + check=True, timeout=600) + build = primitives / 'TEST-reader-build' + bundle, receipt = build / 'reader.js', build / 'build-receipt.json' + subprocess.run([sys.executable, '-B', str(external_helper('release-opencode-reader.py')), + '--execute', '--candidate-repo', str(repo), '--root', str(primitives / 'TEST-reader-native'), + '--manifest', str(manifest), '--manifest-sha256', manifest_sha, + '--bundle', str(bundle), '--bundle-sha256', r.digest(bundle), + '--build-receipt', str(receipt), '--build-receipt-sha256', r.digest(receipt), + '--report', str(reader_report), *platform_args], check=True, timeout=180) + sealed = subprocess.check_output([sys.executable, '-B', str(external_helper('release-opencode-business-proof.py')), + '--manifest', str(manifest), '--manifest-sha256', manifest_sha, + '--source-root', str(repo), '--clock-report', str(clock_report), + '--clock-report-sha256', r.digest(clock_report), '--reader-report', str(reader_report), + '--reader-report-sha256', r.digest(reader_report), + '--output', str(primitives / 'sealed'), *platform_args], text=True, timeout=60) + record = json.loads(sealed) + proof = pathlib.Path(record['proof']).resolve(strict=True) + require(proof == (primitives / 'sealed/business-proof.json').resolve(strict=True) and + r.digest(proof) == record['sha256'], 'fresh_semantically_sealed_business_proof_required') + return proof, record['sha256'] + + +def qualify(execute_native, prepare=False): + run, candidate_sha, parent_sha = bindings() + require(not any(os.environ.get(key) for key in ('GH_TOKEN', 'GITHUB_TOKEN')), + 'github_credentials_forbidden_in_native_fixture') + repo = pathlib.Path.cwd().resolve() + fixture = repo / 'scripts/testdata/opencode-native-e2e/ci_inputs.py' + spec = importlib.util.spec_from_file_location('exact_release_inputs', fixture) + inputs = importlib.util.module_from_spec(spec) + spec.loader.exec_module(inputs) + r = inputs.r + manifest = repo / os.environ['AN_MANIFEST'] + manifest_sha = os.environ['AN_MANIFEST_SHA256'] + os_name, arch, version = (os.environ[key] for key in ('AN_OS', 'AN_ARCH', 'AN_VERSION')) + require((os_name, arch, version) in r.RELEASE_CELLS, 'seven_release_cells_only') + m, cell, files, binary, archive = r.load_manifest(manifest, os_name, arch, version, manifest_sha) + require(m.get('releaseScope') == 'linux-windows' and + m['candidateCommit'] == m['buildRevision'] == candidate_sha, + 'immutable_partial_release_custody_required') + binary_name = f'claude-notifications-{os_name}-{arch}' + ('.exe' if os_name == 'windows' else '') + require(binary == (repo / 'dist' / binary_name).resolve(strict=True), 'normal_release_binary_required') + require(r.digest(repo / '.task-tools/artifacts/custody/release-opencode-inputs.tar.gz') == parent_sha, + 'same_original_parent_archive_required') + checkout = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + dirty = subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=no'], text=True) + dirty += subprocess.check_output(['git', 'ls-files', '--others', '--exclude-standard', '--', '.', + ':(exclude).task-tools/artifacts'], text=True) + build = subprocess.check_output(['go', 'version', '-m', str(binary)], text=True) + r.verify_source_binding(m, checkout, build, dirty) + if prepare: + prepare_primitives(repo) + return 0 + if not execute_native: + print('Verified original binary path, source assets and clean Go revision') + return 0 + metadata = {'recoveryHarnessSHA256': hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest(), + 'recoveryOriginalRunID': run, 'recoveryCandidateCommit': candidate_sha, + 'recoveryParentArchiveSHA256': parent_sha} + if os_name == 'linux': + require(sys.platform == 'linux' and os.geteuid() != 0, 'unprivileged_linux_fixture_required') + baseline = os.environ.get('AN_HOST_NETNS', '') + + def guard(): + metadata['recoveryNetworkNamespaceProof'] = namespace_proof( + baseline, os.readlink('/proc/self/ns/net'), [name for _, name in socket.if_nameindex()]) + + r.network_guard = guard # The sole override; candidate/fixture source remains immutable. + guard() + metadata['recoveryNetworkIsolation'] = True + else: + require(sys.platform == 'win32', 'actual_windows_fixture_required') + metadata['recoveryNetworkIsolation'] = False + image = binary.read_bytes() + windows_release_bindings(candidate_sha, manifest_sha, parent_sha, hashlib.sha256(image).hexdigest()) + embedded = windows_embedded_asset(files['embedded'].read_bytes(), image, WINDOWS_EMBEDDED_OFFSET) + del image + receipts = metadata['recoveryWindowsRenderCustody'] = [] + metadata.update(canonicalGitAssetSHA256=WINDOWS_SOURCE_SHA, platformEmbeddedSHA256=WINDOWS_EMBEDDED_SHA) + r.registration = windows_registration_oracle(r.registration, receipts) + acl_receipts = metadata['recoveryWindowsACLPreparation'] = [] + acl_report = inputs.artifacts / 'windows-acl-preparation.json' + r.prepare_sandbox_root = windows_acl_preparer(repo, os.environ.get('AN_GO_MODULE_CACHE', ''), + acl_receipts, acl_report) + proof, proof_sha = fresh_business_proof(repo, manifest, manifest_sha, run, + parent_sha, (os_name, arch, version), r) + metadata.update(recoveryObservationScope='installed_business_only', + recoveryFreshBusinessProofSHA256=proof_sha, + recoveryPrimitiveHarnessesSHA256={name: r.digest(external_helper(name)) for name in + ('release-opencode-clock.py', 'release-opencode-reader.py', + 'release-opencode-reader.mts', 'release-opencode-business-proof.py')}) + report = inputs.artifacts / 'native-report.json' + sys.argv = [str(repo / 'scripts/opencode-native-e2e.py'), '--manifest', str(manifest), + '--manifest-sha256', manifest_sha, '--binary', str(binary), '--archive', str(archive), + '--os', os_name, '--arch', arch, '--version', version, + '--suite', 'business', '--business-proof', str(proof), '--business-proof-sha256', proof_sha, + '--report', str(report)] + if os_name == 'linux': + sys.argv += ['--receiver-record', str(inputs.artifacts / 'receiver-record.json')] + code = r.main() + result = json.loads(report.read_text()) + if os_name == 'windows' and code == 0: + require(len(metadata['recoveryWindowsRenderCustody']) == 3, 'three_actual_registration_receipts_required') + result.update(metadata) + r.write_json(report, result) + return code + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + modes = parser.add_mutually_exclusive_group(required=True) + for name in ('verify-origin', 'verify-inputs', 'prepare-primitives', 'execute', 'self-test'): + modes.add_argument('--' + name, action='store_true') + args = parser.parse_args() + if args.self_test: + self_test() + elif args.verify_origin: + verify_origin() + else: + return qualify(args.execute, args.prepare_primitives) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/testdata/opencode-native-e2e/ci_inputs.py b/scripts/testdata/opencode-native-e2e/ci_inputs.py index c10618c43..5f01b47e0 100644 --- a/scripts/testdata/opencode-native-e2e/ci_inputs.py +++ b/scripts/testdata/opencode-native-e2e/ci_inputs.py @@ -63,17 +63,24 @@ def main(): artifacts.mkdir(parents=True, exist_ok=True) try: if '--stage-evidence' in sys.argv: - url = urllib.parse.urlsplit(os.environ.get('AN_EVIDENCE_URL', '')) sha = os.environ.get('AN_EVIDENCE_SHA256', '') - r.require(url.scheme == 'https' and url.hostname and not url.username and not url.password - and not url.query and not url.fragment and re.fullmatch('[0-9a-f]{64}', sha), 'external_parent_evidence_required') - archive = artifacts / 'parent-inputs.tar.gz' - with urllib.request.urlopen(url.geturl(), timeout=30) as source, archive.open('xb') as out: - total = 0 - while block := source.read(1024 * 1024): - total += len(block) - r.require(total <= 2 * 1024**3, 'parent_archive_size') - out.write(block) + local = os.environ.get('AN_EVIDENCE_ARCHIVE', '') + if local: + archive = pathlib.Path(local).absolute() + r.require(archive.is_file() and not archive.is_symlink() + and archive.resolve().is_relative_to(artifacts) + and not any(p.is_symlink() for p in archive.parents), 'local_parent_archive_invalid') + else: + url = urllib.parse.urlsplit(os.environ.get('AN_EVIDENCE_URL', '')) + r.require(url.scheme == 'https' and url.hostname and not url.username and not url.password + and not url.query and not url.fragment and re.fullmatch('[0-9a-f]{64}', sha), 'external_parent_evidence_required') + archive = artifacts / 'parent-inputs.tar.gz' + with urllib.request.urlopen(url.geturl(), timeout=30) as source, archive.open('xb') as out: + total = 0 + while block := source.read(1024 * 1024): + total += len(block) + r.require(total <= 2 * 1024**3, 'parent_archive_size') + out.write(block) manifest, manifest_sha = stage_parent_archive(archive, sha, artifacts/'parent-inputs') # Outputs contain only fixed repository path/hex digest, never source URL. with pathlib.Path(os.environ['GITHUB_OUTPUT']).open('a') as outputs: diff --git a/scripts/testdata/opencode-native-e2e/test_fixture.py b/scripts/testdata/opencode-native-e2e/test_fixture.py index 5f987befb..a68c6da5e 100644 --- a/scripts/testdata/opencode-native-e2e/test_fixture.py +++ b/scripts/testdata/opencode-native-e2e/test_fixture.py @@ -27,6 +27,31 @@ def wire(kind, seq, session='root', **data): class FixtureTests(unittest.TestCase): + def test_captured_host_namespace_preserves_isolation_without_proc1_access(self): + from unittest.mock import patch + + def read_namespace(path): + if path == '/proc/self/ns/net': + return 'net:[11]' + raise PermissionError('root-owned namespace') + + with patch.object(r.sys, 'platform', 'linux'), \ + patch.object(r.os, 'readlink', side_effect=read_namespace) as readlink, \ + patch.object(r.socket, 'if_nameindex', return_value=[(1, 'lo')]), \ + patch.dict(r.os.environ, {'AN_HOST_NETNS': 'net:[10]'}): + r.network_guard() + readlink.assert_called_once_with('/proc/self/ns/net') + for host, interfaces, failure in ( + ('net:[11]', [(1, 'lo')], 'new_private_netns_required'), + ('', [(1, 'lo')], 'host_network_namespace_invalid'), + ('not-a-namespace', [(1, 'lo')], 'host_network_namespace_invalid'), + ('net:[10]', [(1, 'lo'), (2, 'eth0')], 'loopback_only_required')): + with self.subTest(host=host, interfaces=interfaces), \ + patch.dict(r.os.environ, {'AN_HOST_NETNS': host}), \ + patch.object(r.socket, 'if_nameindex', return_value=interfaces), \ + self.assertRaisesRegex(r.Unqualified, failure): + r.network_guard() + def test_duplicate_manifest_key_fails_before_any_launch(self): with tempfile.TemporaryDirectory(prefix='TEST-parser-',dir=artifacts) as d: path=pathlib.Path(d)/'manifest.json' diff --git a/scripts/testdata/opencode-native-e2e/test_release_inputs.py b/scripts/testdata/opencode-native-e2e/test_release_inputs.py new file mode 100644 index 000000000..0667e25cd --- /dev/null +++ b/scripts/testdata/opencode-native-e2e/test_release_inputs.py @@ -0,0 +1,128 @@ +"""Custody regressions only: no builds, native hosts, providers or notifiers.""" +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +REPO = Path(__file__).resolve().parents[3] +SPEC = importlib.util.spec_from_file_location('release_inputs', REPO / 'scripts/release-opencode-inputs.py') +release = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release) + + +class ReleaseInputsTests(unittest.TestCase): + def test_partial_custody_requires_explicit_scope_and_exact_seven_cells(self): + # RED if a missing platform silently weakens the eleven-cell default. + template = json.loads((release.FIXTURES / 'manifest.template.json').read_text()) + release.r.custody_cells(template, ('darwin', 'arm64', '2.0.21')) + partial = {**template, 'cells': [cell for cell in template['cells'] if cell['os'] != 'darwin']} + with self.assertRaisesRegex(release.r.Unqualified, 'eleven_native_cells_required'): + release.r.custody_cells(partial, ('linux', 'amd64', '1.18.33')) + partial['releaseScope'] = 'linux-windows' + release.r.custody_cells(partial, ('windows', 'amd64', '2.0.21')) + with self.assertRaisesRegex(release.r.Unqualified, 'requested_cell_outside_custody_scope'): + release.r.custody_cells(partial, ('darwin', 'arm64', '2.0.21')) + partial['cells'][-1] = partial['cells'][0] + with self.assertRaisesRegex(release.r.Unqualified, 'matrix_incomplete'): + release.r.custody_cells(partial, ('linux', 'amd64', '1.18.33')) + + def test_partial_release_requires_all_requested_binaries_and_omits_skipped_platforms(self): + # RED if Windows absence is ignored or Mac artifacts enter partial custody. + with tempfile.TemporaryDirectory(prefix='TEST-partial-release-') as directory: + root = Path(directory).resolve() + (root / 'dist').mkdir() + for name in ('claude-notifications-linux-amd64', 'claude-notifications-linux-arm64'): + (root / 'dist' / name).write_bytes(b'TEST custody bytes') + with patch.object(release, 'REPO', root): + with self.assertRaisesRegex(release.r.Unqualified, 'release_input_not_regular'): + release.release_manifest('linux-windows') + (root / 'dist/claude-notifications-windows-amd64.exe').write_bytes(b'TEST Windows custody bytes') + manifest = release.release_manifest('linux-windows') + self.assertEqual(manifest['releaseScope'], 'linux-windows') + self.assertEqual(len(manifest['cells']), 7) + self.assertEqual({cell['os'] for cell in manifest['cells']}, {'linux', 'windows'}) + for cell in manifest['cells']: + binary = release.r.checked_file(root, cell['candidate']) + self.assertTrue(binary.read_bytes().startswith(b'TEST')) + with self.assertRaisesRegex(release.r.Unqualified, 'release_input_not_regular'): + release.release_manifest('all') + + def test_reviewed_sdk_index_retains_actual_original_bytes(self): + archive = REPO / 'opencode-plugin/vendor/universal-agent-plugins-opencode-events-0.3.0.tgz' + with tempfile.TemporaryDirectory(prefix='TEST-release-inputs-') as directory: + output = Path(directory).resolve() + release.reviewed_sdk_index(archive, output) + body = (output / 'sdk-index.js').read_bytes() + self.assertEqual(len(body), 311) + self.assertEqual(hashlib.sha256(body).hexdigest(), + '8e0c343aa9ea29bfce3e4d53c0d979ffc447af96c36b10c5253e3a766a6454a5') + with tarfile.open(archive) as packed: + self.assertEqual(body, packed.extractfile('package/index.js').read()) + + def test_corrupted_raw_sdk_index_rejected_before_output(self): + with tempfile.TemporaryDirectory(prefix='TEST-release-inputs-') as directory: + root = Path(directory).resolve() + archive = root / 'changed.tar.gz' + with tarfile.open(archive, 'w:gz') as packed: + member = tarfile.TarInfo('package/index.js') + member.size = 311 + packed.addfile(member, io.BytesIO(b'x' * 311)) + output = root / 'output' + output.mkdir() + with self.assertRaisesRegex(release.r.Unqualified, 'reviewed_sdk_index_differs'): + release.reviewed_sdk_index(archive, output) + self.assertEqual(list(output.iterdir()), []) + + def test_qualification_custody_reads_actual_current_go_source(self): + archive = REPO / 'opencode-plugin/vendor/universal-agent-plugins-opencode-events-0.3.0.tgz' + with tempfile.TemporaryDirectory(prefix='TEST-release-inputs-') as directory: + records = release.custody_sources(archive, Path(directory).resolve()) + source = release.r.checked_file(REPO, records['qualificationSource']) + self.assertEqual(source, REPO / 'internal/opencodecodec/qualification_rows.go') + self.assertEqual(hashlib.sha256(source.read_bytes()).hexdigest(), + 'e879efac04bad423a2e1adbc1b7712b5e9f21d245e5feb66db974a312c08d925') + + def test_archive_is_repeatable_and_contains_only_original_bytes(self): + with tempfile.TemporaryDirectory(prefix='TEST-release-inputs-') as directory: + root = Path(directory) + source = root / 'source' + source.mkdir() + (source / 'manifest.json').write_bytes(b'{"custody":true}\n') + first, second = root / 'first.tar.gz', root / 'second.tar.gz' + self.assertEqual(release.seal_archive(source, first), release.seal_archive(source, second)) + with tarfile.open(first) as archive: + self.assertEqual(archive.getnames(), ['manifest.json']) + self.assertEqual(archive.extractfile('manifest.json').read(), b'{"custody":true}\n') + self.assertEqual(archive.getmember('manifest.json').mtime, 0) + + def test_even_dangling_symlink_cannot_enter_custody(self): + with tempfile.TemporaryDirectory(prefix='TEST-release-inputs-') as directory: + root = Path(directory) + source = root / 'source' + source.mkdir() + (source / 'manifest.json').write_text('{}') + (source / 'missing').symlink_to(root / 'absent') + with self.assertRaisesRegex(release.r.Unqualified, 'parent_member_symlink'): + release.seal_archive(source, root / 'inputs.tar.gz') + + def test_archive_digest_failure_precedes_extraction(self): + with tempfile.TemporaryDirectory(prefix='TEST-release-inputs-') as directory: + root = Path(directory).resolve() + source = root / 'source' + source.mkdir() + (source / 'manifest.json').write_text('{}') + archive = root / 'inputs.tar.gz' + release.seal_archive(source, archive) + target = root / 'must-not-exist' + with self.assertRaisesRegex(release.r.Unqualified, 'parent_archive_hash_mismatch'): + release.inputs.stage_parent_archive(archive, '0' * 64, target) + self.assertFalse(target.exists()) + + +if __name__ == '__main__': + unittest.main()