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 @@
[](https://pkg.go.dev/github.com/777genius/agent-notifications)
[](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.
@@ -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