diff --git a/.diffex/prompts/cl.md b/.diffex/prompts/cl.md deleted file mode 100644 index 9afd7b8d5..000000000 --- a/.diffex/prompts/cl.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -description: Audit changelog entries before release ---- -Audit changelog entries for all commits since the last release. - -## Process - -1. **Find the last release tag:** - ```bash - git tag --sort=-version:refname | head -1 - ``` - -2. **List all commits since that tag:** - ```bash - git log ..HEAD --oneline - ``` - -3. **Read each package's [Unreleased] section:** - - packages/ai/CHANGELOG.md - - packages/tui/CHANGELOG.md - - packages/coding-agent/CHANGELOG.md - -4. **For each commit, check:** - - Skip: changelog updates, doc-only changes, release housekeeping - - Skip: changes to generated model catalogs (for example `packages/ai/src/models.generated.ts`) unless accompanied by an intentional product-facing change in non-generated source/docs. - - Determine which package(s) the commit affects (use `git show --stat`) - - Verify a changelog entry exists in the affected package(s) - - For external contributions (PRs), verify format: `Description ([#N](url) by [@user](url))` - -5. **Cross-package duplication rule:** - Changes in `ai`, `agent` or `tui` that affect end users should be duplicated to `coding-agent` changelog, since coding-agent is the user-facing package that depends on them. - -6. **Add New Features section after changelog fixes:** - - Insert a `### New Features` section at the start of `## [Unreleased]` in `packages/coding-agent/CHANGELOG.md`. - - Propose the top new features to the user for confirmation before writing them. - - Link to relevant docs and sections whenever possible. - -7. **Report:** - - List commits with missing entries - - List entries that need cross-package duplication - - Add any missing entries directly - -## Changelog Format Reference - -Sections (in order): -- `### Breaking Changes` - API changes requiring migration -- `### Added` - New features -- `### Changed` - Changes to existing functionality -- `### Fixed` - Bug fixes -- `### Removed` - Removed features - -Attribution: -- Internal: `Fixed foo ([#123](https://github.com/diffexai/diffex/issues/123))` -- External: `Added bar ([#456](https://github.com/diffexai/diffex/pull/456) by [@user](https://github.com/user))` diff --git a/.dockerignore b/.dockerignore index 5b1b5dfcb..e6526b071 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,3 +17,5 @@ packages/coding-agent/binaries **/.env **/.env.* **/__pycache__ + +/docs diff --git a/docs/assets/daytona-startup-grid.png b/.github/assets/daytona-startup-grid.png similarity index 100% rename from docs/assets/daytona-startup-grid.png rename to .github/assets/daytona-startup-grid.png diff --git a/docs/assets/diffex-wordmark.png b/.github/assets/diffex-wordmark.png similarity index 100% rename from docs/assets/diffex-wordmark.png rename to .github/assets/diffex-wordmark.png diff --git a/docs/assets/e2b-startups.png b/.github/assets/e2b-startups.png similarity index 100% rename from docs/assets/e2b-startups.png rename to .github/assets/e2b-startups.png diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index acccd8a5a..58b3d20ce 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -108,7 +108,7 @@ jobs: if: steps.restore.outputs.restored != 'true' run: | npm run check - ./test.sh + ./scripts/test.sh git diff --exit-code HEAD - name: Prepare reviewed tarballs and private source snapshot if: steps.restore.outputs.restored != 'true' @@ -143,8 +143,153 @@ jobs: with: source_commit: ${{ needs.resolve-source.outputs.commit }} + prepare-homebrew: + needs: [resolve-source, prepare-artifacts] + if: ${{ vars.HOMEBREW_CANDIDATES_ENABLED == 'true' || vars.HOMEBREW_DISTRIBUTION_ENABLED == 'true' }} + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + include: + - { runner: macos-15, platform: darwin-arm64 } + - { runner: macos-15-intel, platform: darwin-x64 } + - { runner: ubuntu-24.04-arm, platform: linux-arm64 } + - { runner: ubuntu-24.04, platform: linux-x64 } + runs-on: ${{ matrix.runner }} + permissions: + contents: read + actions: read + env: + DIFFEX_SOURCE_COMMIT: ${{ needs.resolve-source.outputs.commit }} + HOMEBREW_PLATFORM: ${{ matrix.platform }} + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + PYTHONDONTWRITEBYTECODE: '1' + steps: + - name: Checkout trusted workflow control code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Setup Node for preparation only + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.19.0' + - name: Checkout independently verified tagged source + run: node scripts/checkout-approved-release.mjs + - name: Restore exact retained platform bytes before considering preparation + id: restore + env: + GH_TOKEN: ${{ github.token }} + RECOVERY_RUN_ID: ${{ inputs.recovery_run_id }} + run: node scripts/restore-release-artifacts.mjs + - name: Download exact reviewed npm input + env: + GH_TOKEN: ${{ github.token }} + run: gh run download "$GITHUB_RUN_ID" --repo diffexai/diffex --name "private-release-$DIFFEX_SOURCE_COMMIT" --dir .artifacts/release + - name: Install Linux preparation and acceptance prerequisites + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y gnupg python3 + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + echo /home/linuxbrew/.linuxbrew/bin >> "$GITHUB_PATH" + brew install fd ripgrep + - name: Install macOS preparation and acceptance prerequisites + if: runner.os == 'macOS' + run: brew install gnupg fd ripgrep + - name: Assemble reviewed native archive without lifecycle scripts + if: steps.restore.outputs.restored != 'true' + run: node scripts/prepare-homebrew-archive.mjs .artifacts/release ".artifacts/homebrew/$HOMEBREW_PLATFORM" + - name: Accept retained archive outside the workspace without system Node or npm + run: | + version="$(cat packages/coding-agent/package.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')" + base=".artifacts/homebrew/$HOMEBREW_PLATFORM/diffex-$version-$HOMEBREW_PLATFORM" + node scripts/homebrew-acceptance.mjs "$base.tar.gz" "$base.json" .artifacts/homebrew-acceptance + - name: Exercise real Homebrew install upgrade and uninstall against retained bytes + run: | + python3 scripts/daytona-behaviors/homebrew.py \ + --artifacts ".artifacts/homebrew/$HOMEBREW_PLATFORM" \ + --output .artifacts/homebrew-lifecycle --commit "$DIFFEX_SOURCE_COMMIT" --lifecycle-only + - name: Retain only reviewed platform archives and records + if: steps.restore.outputs.upload == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: homebrew-${{ matrix.platform }}-${{ needs.resolve-source.outputs.commit }} + path: | + .artifacts/homebrew/${{ matrix.platform }}/*.tar.gz + .artifacts/homebrew/${{ matrix.platform }}/*.json + if-no-files-found: error + retention-days: 90 + - name: Retain sanitized archive acceptance evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: homebrew-acceptance-${{ matrix.platform }}-${{ needs.resolve-source.outputs.commit }} + path: | + .artifacts/homebrew-acceptance/homebrew-acceptance.json + .artifacts/homebrew-lifecycle/results.json + .artifacts/homebrew-lifecycle/results.xml + .artifacts/homebrew-lifecycle/*/lifecycle.json + if-no-files-found: error + retention-days: 90 + + retain-homebrew: + needs: [resolve-source, prepare-homebrew] + if: ${{ vars.HOMEBREW_DISTRIBUTION_ENABLED == 'true' }} + runs-on: ubuntu-24.04 + environment: homebrew-release + permissions: + contents: read + actions: read + env: + DIFFEX_SOURCE_COMMIT: ${{ needs.resolve-source.outputs.commit }} + HOMEBREW_RELEASE: 'true' + steps: + - name: Checkout immutable release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve-source.outputs.commit }} + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.19.0' + - name: Restore complete signed release before considering aggregation + id: restore + env: + GH_TOKEN: ${{ github.token }} + RECOVERY_RUN_ID: ${{ inputs.recovery_run_id }} + run: node scripts/restore-release-artifacts.mjs + - name: Download retained approved inputs + if: steps.restore.outputs.restored != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh run download "$GITHUB_RUN_ID" --repo diffexai/diffex --name "private-release-$DIFFEX_SOURCE_COMMIT" --dir .artifacts/release + for platform in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do + gh run download "$GITHUB_RUN_ID" --repo diffexai/diffex --name "homebrew-$platform-$DIFFEX_SOURCE_COMMIT" --dir ".artifacts/homebrew/$platform" + done + node scripts/homebrew-release.mjs collect .artifacts/homebrew .artifacts/homebrew-release + - name: Sign approved checksum list with the protected release credential + if: steps.restore.outputs.restored != 'true' + env: + HOMEBREW_SIGNING_KEY: ${{ secrets.HOMEBREW_SIGNING_KEY }} + run: node scripts/homebrew-release.mjs sign .artifacts/homebrew-release + - name: Verify complete retained signed release + run: node scripts/homebrew-release.mjs verify .artifacts/homebrew-release + - name: Retain exact signed public bytes before any publication + if: steps.restore.outputs.upload == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: homebrew-release-${{ needs.resolve-source.outputs.commit }} + path: .artifacts/homebrew-release/ + if-no-files-found: error + retention-days: 90 + publish-npm: - needs: [resolve-source, prepare-artifacts, native-acceptance] + needs: [resolve-source, prepare-artifacts, native-acceptance, prepare-homebrew, retain-homebrew] + if: ${{ always() && needs.native-acceptance.result == 'success' && (needs.prepare-homebrew.result == 'success' || needs.prepare-homebrew.result == 'skipped') && (needs.retain-homebrew.result == 'success' || needs.retain-homebrew.result == 'skipped') }} runs-on: ubuntu-latest environment: npm-publish permissions: @@ -173,8 +318,38 @@ jobs: - name: Publish or verify exact npm tarballs run: node scripts/publish.mjs --artifacts .artifacts/release + publish-homebrew: + needs: [resolve-source, publish-npm, retain-homebrew] + if: ${{ vars.HOMEBREW_DISTRIBUTION_ENABLED == 'true' }} + runs-on: ubuntu-24.04 + environment: homebrew-release + permissions: + contents: write + actions: read + env: + DIFFEX_SOURCE_COMMIT: ${{ needs.resolve-source.outputs.commit }} + steps: + - name: Checkout immutable release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve-source.outputs.commit }} + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.19.0' + - name: Download complete retained signed release + env: + GH_TOKEN: ${{ github.token }} + run: gh run download "$GITHUB_RUN_ID" --repo diffexai/diffex --name "homebrew-release-$DIFFEX_SOURCE_COMMIT" --dir .artifacts/homebrew-release + - name: Publish immutable assets without advancing latest and verify anonymously + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/homebrew-release.mjs publish .artifacts/homebrew-release + announce-diffex-release: - needs: [resolve-source, publish-npm] + needs: [resolve-source, publish-npm, publish-homebrew] + if: ${{ always() && needs.publish-npm.result == 'success' && (needs.publish-homebrew.result == 'success' || needs.publish-homebrew.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: 20 environment: diffex-release-upload @@ -182,11 +357,12 @@ jobs: group: announce-diffex-release cancel-in-progress: false permissions: - contents: read + contents: write actions: read env: RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} DIFFEX_SOURCE_COMMIT: ${{ needs.resolve-source.outputs.commit }} + HOMEBREW_ENABLED: ${{ vars.HOMEBREW_DISTRIBUTION_ENABLED }} steps: - name: Checkout immutable release commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -201,6 +377,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: gh run download "$GITHUB_RUN_ID" --repo diffexai/diffex --name "private-release-$DIFFEX_SOURCE_COMMIT" --dir .artifacts/release + - name: Download retained Homebrew bytes when enabled + if: vars.HOMEBREW_DISTRIBUTION_ENABLED == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: gh run download "$GITHUB_RUN_ID" --repo diffexai/diffex --name "homebrew-release-$DIFFEX_SOURCE_COMMIT" --dir .artifacts/homebrew-release - name: Verify AWS CLI run: aws --version - name: Announce verified Diffex release @@ -211,9 +392,59 @@ jobs: AWS_EC2_METADATA_DISABLED: 'true' R2_ENDPOINT: https://6b24afa849f8254ab5e6e2d1956239cc.r2.cloudflarestorage.com run: | + homebrew_args=() + if [[ "$HOMEBREW_ENABLED" == true ]]; then + homebrew_args=(--homebrew-artifacts .artifacts/homebrew-release) + fi node scripts/publish-release-announcement.mjs \ --version "${RELEASE_TAG#v}" \ --source-commit "$DIFFEX_SOURCE_COMMIT" \ --artifacts .artifacts/release \ --bucket diffex-artifacts \ - --endpoint "$R2_ENDPOINT" + --endpoint "$R2_ENDPOINT" "${homebrew_args[@]}" + - name: Promote GitHub latest monotonically after verified announcement + if: vars.HOMEBREW_DISTRIBUTION_ENABLED == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/homebrew-release.mjs promote .artifacts/homebrew-release + + update-homebrew-tap: + needs: [resolve-source, announce-diffex-release] + if: ${{ vars.HOMEBREW_DISTRIBUTION_ENABLED == 'true' }} + runs-on: ubuntu-24.04 + environment: homebrew-tap + permissions: + contents: read + actions: read + env: + DIFFEX_SOURCE_COMMIT: ${{ needs.resolve-source.outputs.commit }} + steps: + - name: Checkout immutable release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.resolve-source.outputs.commit }} + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.19.0' + - name: Download retained signed Homebrew release + env: + GH_TOKEN: ${{ github.token }} + run: gh run download "$GITHUB_RUN_ID" --repo diffexai/diffex --name "homebrew-release-$DIFFEX_SOURCE_COMMIT" --dir .artifacts/homebrew-release + - name: Reverify public release bytes before issuing a tap credential + run: node scripts/homebrew-release.mjs verify-public .artifacts/homebrew-release + - name: Issue a repository-scoped short-lived GitHub App token + id: tap-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.HOMEBREW_TAP_APP_ID }} + private-key: ${{ secrets.HOMEBREW_TAP_APP_PRIVATE_KEY }} + owner: diffexai + repositories: homebrew-tap + permission-contents: write + permission-pull-requests: write + - name: Open the version and checksum update PR without merging + env: + HOMEBREW_TAP_TOKEN: ${{ steps.tap-token.outputs.token }} + run: node scripts/update-homebrew-tap.mjs --manifest .artifacts/homebrew-release/homebrew-manifest.json diff --git a/.github/workflows/ci-benchmark.yml b/.github/workflows/ci-benchmark.yml index e2fe5248d..f5fde9dca 100644 --- a/.github/workflows/ci-benchmark.yml +++ b/.github/workflows/ci-benchmark.yml @@ -32,7 +32,7 @@ jobs: run: | for sample in 1 2 3; do echo "Worker sample $sample: pool=$POOL workers=$WORKERS" - ./test.sh coding-agent --pool="$POOL" --maxWorkers="$WORKERS" --isolate --experimental.fsModuleCache=false + ./scripts/test.sh coding-agent --pool="$POOL" --maxWorkers="$WORKERS" --isolate --experimental.fsModuleCache=false done cache-seed: @@ -44,7 +44,7 @@ jobs: - uses: ./.github/actions/setup id: setup - name: Populate a cold module cache - run: ./test.sh coding-agent --pool=forks --maxWorkers=1 --isolate + run: ./scripts/test.sh coding-agent --pool=forks --maxWorkers=1 --isolate - name: Measure cache save uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -72,4 +72,4 @@ jobs: key: ci-benchmark-${{ runner.os }}-${{ runner.arch }}-node-${{ steps.setup.outputs.node-version }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} fail-on-cache-miss: true - name: Measure complete suite - run: ./test.sh coding-agent --pool=forks --maxWorkers=1 --isolate + run: ./scripts/test.sh coding-agent --pool=forks --maxWorkers=1 --isolate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f82ba2c3..91a2d2b6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: run: npm install --global --ignore-scripts pnpm@10.28.2 - name: Test scripts and other workspaces - run: ./test.sh other --shard=${{ matrix.shard }}/2 + run: ./scripts/test.sh other --shard=${{ matrix.shard }}/2 coding-agent: runs-on: ubuntu-latest @@ -75,7 +75,7 @@ jobs: restore-keys: ${{ steps.vitest-cache-key.outputs.prefix }} - name: Test - run: ./test.sh coding-agent --shard=${{ matrix.shard }}/4 --pool=forks --maxWorkers=2 --isolate + run: ./scripts/test.sh coding-agent --shard=${{ matrix.shard }}/4 --pool=forks --maxWorkers=2 --isolate - name: Save Vitest module cache if: success() && vars.VITEST_FS_CACHE == 'true' && steps.vitest-cache.outputs.cache-hit != 'true' diff --git a/.github/workflows/daytona-behaviors.yml b/.github/workflows/daytona-behaviors.yml index fbb0de4f6..beece0f29 100644 --- a/.github/workflows/daytona-behaviors.yml +++ b/.github/workflows/daytona-behaviors.yml @@ -4,6 +4,11 @@ on: push: branches: [main] workflow_dispatch: + inputs: + homebrew_candidate: + description: Run real Homebrew lifecycle against retained prepublication candidate bytes + type: boolean + default: false permissions: contents: read @@ -260,8 +265,146 @@ jobs: if-no-files-found: error retention-days: 30 + prepare-homebrew: + needs: prepare + if: ${{ vars.HOMEBREW_QUALIFIED == 'true' || (github.event_name == 'workflow_dispatch' && inputs.homebrew_candidate) }} + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Checkout exact event commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Setup Node for archive preparation and harness checks + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.19.0' + - name: Download exact retained npm candidate + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: daytona-release-${{ github.sha }}-${{ github.run_attempt }} + path: .artifacts/release + - name: Install runtime signature verification prerequisite + run: | + sudo apt-get update + sudo apt-get install -y gnupg + - name: Prepare native archive from retained candidate without rebuilding + run: | + node scripts/prepare-homebrew-archive.mjs .artifacts/release .artifacts/daytona-homebrew-candidate + node --test scripts/homebrew-acceptance.test.mjs + - name: Bundle Homebrew behavior harness only + run: | + mkdir -p .artifacts/daytona-homebrew-bundle + tar -czf .artifacts/daytona-homebrew-bundle/bundle.tar.gz \ + scripts/homebrew-acceptance.mjs scripts/homebrew-extract.py \ + scripts/fixtures/homebrew-runtime-operations.mjs \ + scripts/npm-lifecycle-registry.mjs \ + scripts/daytona-behaviors/homebrew.py scripts/daytona-behaviors/installed.py \ + scripts/daytona-behaviors/fixture.mjs scripts/daytona-behaviors/baselines + sha256sum .artifacts/daytona-homebrew-bundle/bundle.tar.gz > .artifacts/daytona-homebrew-bundle/SHA256SUMS + - name: Retain only native candidate archive and identity record + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: daytona-homebrew-candidate-${{ github.sha }}-${{ github.run_attempt }} + path: | + .artifacts/daytona-homebrew-candidate/*.tar.gz + .artifacts/daytona-homebrew-candidate/*.json + if-no-files-found: error + retention-days: 30 + - name: Retain exact Homebrew behavior bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: daytona-homebrew-bundle-${{ github.sha }}-${{ github.run_attempt }} + path: .artifacts/daytona-homebrew-bundle/ + if-no-files-found: error + retention-days: 30 + + homebrew: + needs: prepare-homebrew + runs-on: ubuntu-24.04 + timeout-minutes: 35 + environment: daytona-behaviors + env: + DAYTONA_BEHAVIOR_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-homebrew + # Separate preprovisioned non-root Linuxbrew snapshot; never install brew at run time. + DAYTONA_SNAPSHOT: ${{ vars.DAYTONA_HOMEBREW_SNAPSHOT }} + steps: + - name: Checkout exact event commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Install pinned Daytona SDK in an isolated environment + run: | + python3 -m venv "$RUNNER_TEMP/daytona-venv" + "$RUNNER_TEMP/daytona-venv/bin/python" -m pip install -r scripts/daytona-behaviors/requirements.txt + - name: Download retained native archive and record + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: daytona-homebrew-candidate-${{ github.sha }}-${{ github.run_attempt }} + path: .artifacts/daytona-homebrew-candidate + - name: Download exact Homebrew behavior bundle + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: daytona-homebrew-bundle-${{ github.sha }}-${{ github.run_attempt }} + path: .artifacts/daytona-homebrew-bundle + - name: Verify retained harness and require provisioned Homebrew snapshot + run: | + sha256sum -c .artifacts/daytona-homebrew-bundle/SHA256SUMS + test -n "$DAYTONA_SNAPSHOT" + - name: Run real brew lifecycle and installed behaviors in Daytona + id: run + timeout-minutes: 25 + env: + DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} + run: | + "$RUNNER_TEMP/daytona-venv/bin/python" scripts/daytona-behaviors/daytona_runner.py run \ + --artifacts .artifacts/daytona-homebrew-candidate --bundle .artifacts/daytona-homebrew-bundle/bundle.tar.gz \ + --snapshot "$DAYTONA_SNAPSHOT" --output .artifacts/daytona-homebrew \ + --mode homebrew --commit "$DIFFEX_SOURCE_COMMIT" --run-id "$DAYTONA_BEHAVIOR_RUN_ID" \ + --timeout-seconds 1200 + - name: Recover Homebrew evidence and independently confirm sandbox cleanup + id: recover + if: ${{ always() && steps.run.outcome != 'skipped' }} + timeout-minutes: 5 + env: + DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} + run: | + "$RUNNER_TEMP/daytona-venv/bin/python" scripts/daytona-behaviors/daytona_runner.py recover \ + --output .artifacts/daytona-homebrew + - name: Record Homebrew execution and cleanup outcomes + if: always() + env: + EXECUTION: ${{ steps.run.outcome }} + RECOVERY: ${{ steps.recover.outcome }} + run: | + mkdir -p .artifacts/daytona-homebrew + jq -n --arg commit "$DIFFEX_SOURCE_COMMIT" --arg runId "$DAYTONA_BEHAVIOR_RUN_ID" \ + --arg execution "$EXECUTION" --arg recovery "$RECOVERY" \ + '{commit: $commit, runId: $runId, mode: "homebrew", execution: $execution, recovery: $recovery}' \ + > .artifacts/daytona-homebrew/workflow-job.json + - name: Guard all Homebrew evidence before publication + id: sanitize + if: always() + env: + DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }} + run: | + python3 scripts/daytona-behaviors/sanitize_evidence.py \ + --root .artifacts/daytona-homebrew --output .artifacts/daytona-homebrew-sanitized \ + --mode deterministic + - name: Retain sanitized Homebrew evidence even on failure + if: ${{ always() && steps.sanitize.outputs.safe_to_upload == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: daytona-evidence-homebrew-${{ github.sha }}-${{ github.run_attempt }} + path: .artifacts/daytona-homebrew-sanitized/ + include-hidden-files: true + if-no-files-found: error + retention-days: 30 + daytona-behaviors: - needs: [prepare, deterministic, live] + needs: [prepare, deterministic, live, homebrew] if: ${{ always() && (github.event_name == 'workflow_dispatch' || vars.DAYTONA_BEHAVIORS_QUALIFIED == 'true') }} runs-on: ubuntu-24.04 timeout-minutes: 10 @@ -270,6 +413,8 @@ jobs: PREPARE_RESULT: ${{ needs.prepare.result }} DETERMINISTIC_RESULT: ${{ needs.deterministic.result }} LIVE_RESULT: ${{ needs.live.result }} + HOMEBREW_RESULT: ${{ needs.homebrew.result }} + HOMEBREW_REQUIRED: ${{ vars.HOMEBREW_QUALIFIED == 'true' || (github.event_name == 'workflow_dispatch' && inputs.homebrew_candidate) }} steps: - name: Aggregate both modes, missing evidence and cleanup failures run: | @@ -278,6 +423,33 @@ jobs: for result in "$PREPARE_RESULT" "$DETERMINISTIC_RESULT" "$LIVE_RESULT"; do if [[ "$result" != success ]]; then failed=1; fi done + if [[ "${HOMEBREW_REQUIRED:-false}" == true ]]; then + if [[ "${HOMEBREW_RESULT:-missing}" != success ]]; then failed=1; fi + evidence=".artifacts/daytona-summary/homebrew" + if ! gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --name "daytona-evidence-homebrew-$DIFFEX_SOURCE_COMMIT-$GITHUB_RUN_ATTEMPT" --dir "$evidence"; then + echo '::error::Missing Homebrew evidence artifact' + failed=1 + else + if ! jq --exit-status '.safe_to_upload == true and .outcome == "clean" and .credential_matches == 0' \ + "$evidence/publication-safety.json" > /dev/null; then failed=1; fi + if ! jq --exit-status --arg commit "$DIFFEX_SOURCE_COMMIT" \ + --arg runId "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-homebrew" \ + '.commit == $commit and .runId == $runId and .mode == "homebrew" and .execution == "success" and .recovery == "success"' \ + "$evidence/workflow-job.json" > /dev/null; then failed=1; fi + if ! jq --exit-status --arg commit "$DIFFEX_SOURCE_COMMIT" \ + --arg runId "$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-homebrew" \ + '.commit == $commit and .run_id == $runId and .mode == "homebrew" and .outcome == "passed" and .reports_complete == true and .cleanup == "confirmed"' \ + "$evidence/orchestration.json" > /dev/null; then failed=1; fi + expected='["homebrew-formula-install","homebrew-retained-archive","homebrew-installation","homebrew-print","homebrew-builtin-auth-transport","homebrew-terminal-tools-cancel-resume","homebrew-formula-upgrade-retention","homebrew-formula-uninstall-retention"]' + if ! jq --exit-status --arg commit "$DIFFEX_SOURCE_COMMIT" --argjson expected "$expected" \ + '.schemaVersion == 1 and .commit == $commit and .mode == "homebrew" and .outcome == "passed" and (.artifactHashes | type == "object" and length > 0) and ((.scenarios | map(.id) | sort) == ($expected | sort)) and all(.scenarios[]; .commit == $commit and .outcome == "passed")' \ + "$evidence/evidence/results.json" > /dev/null; then failed=1; fi + for result in orchestration.xml evidence/results.xml; do + if [[ ! -s "$evidence/$result" ]]; then failed=1; fi + done + fi + fi for mode in deterministic live; do evidence=".artifacts/daytona-summary/$mode" if ! gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \ diff --git a/.github/workflows/runtime-image.yml b/.github/workflows/runtime-image.yml index e9cfc14ef..286bc4859 100644 --- a/.github/workflows/runtime-image.yml +++ b/.github/workflows/runtime-image.yml @@ -30,7 +30,7 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . - file: Dockerfile.runtime + file: scripts/Dockerfile.runtime platforms: linux/amd64 load: true push: false diff --git a/.gitignore b/.gitignore index df9650f91..045f8695f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,9 @@ packages/**/THIRD-PARTY-NOTICES.txt packages/**/distribution-components.json packages/**/distribution-files.json packages/coding-agent/release/ + +# Owner-only instructions and release records +/AGENTS.md +/RELEASING.md +/docs/ +/.diffex/prompts/cl.md diff --git a/AGENTS.md b/AGENTS.md index ab9ad8f6c..839fb75b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,8 @@ # Development Rules +This is a recommended system prompt for developing Diffex. +Releases and changelog maintenance are handled exclusively by the project owner. + ## Conversational Style - Keep answers short and concise @@ -27,7 +30,7 @@ - After code changes (not docs): `npm run check` (full output, no tail). Fix all errors, warnings, and infos before committing. Does not run tests. - Never run `npm run build` or `npm test` unless requested by the user. -- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./test.sh` from the repo root. Otherwise run specific tests from the package root: `node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`. +- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./scripts/test.sh` from the repo root. Otherwise run specific tests from the package root: `node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`. - If you create or modify a test file, run it and iterate on test or implementation until it passes. - For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` + the faux provider. No real provider APIs, keys, or paid tokens. - Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` named `-.test.ts`. @@ -95,69 +98,13 @@ Run the TUI in a controlled terminal (from the repo root): ```bash tmux new-session -d -s diffex-test -x 80 -y 24 -tmux send-keys -t diffex-test "./diffex-test.sh" Enter +tmux send-keys -t diffex-test "./scripts/diffex-test.sh" Enter sleep 3 && tmux capture-pane -t diffex-test -p # capture after startup tmux send-keys -t diffex-test "your prompt here" Enter tmux send-keys -t diffex-test Escape # special keys (also C-o for ctrl+o, etc.) tmux kill-session -t diffex-test ``` -## Changelog - -Location: `packages/*/CHANGELOG.md` (one per package). - -Sections under `## [Unreleased]`: `### Breaking Changes` (API changes requiring migration), `### Added`, `### Changed`, `### Fixed`, `### Removed`. - -Rules: - -- All new entries go under `## [Unreleased]`. Read the full section first and append to existing subsections; never duplicate them. -- Released version sections (e.g. `## [0.12.2]`) are immutable; never modify them. - -Attribution: - -- Internal (from issues): `Fixed foo bar ([#123](https://github.com/diffexai/diffex/issues/123))` -- External contributions: `Added feature X ([#456](https://github.com/diffexai/diffex/pull/456) by [@username](https://github.com/username))` - -## Releasing - -**Lockstep versioning**: all packages share one version; every release updates all together. `patch` = fixes + additions, `minor` = breaking changes. No major releases. - -1. **Update CHANGELOGs**: ask the user whether they ran the `/cl` prompt on the latest commit on `main`. If not, they must run `/cl` first to audit and update each package's `[Unreleased]` section before releasing. - -2. **Local smoke test**: build an unpublished release and smoke test from outside the repo (so it can't resolve workspace files): - ```bash - npm run release:local -- --out /tmp/diffex-local-release --force - cd /tmp - - # Node package install smoke tests - /tmp/diffex-local-release/node/diffex --help - /tmp/diffex-local-release/node/diffex --version - /tmp/diffex-local-release/node/diffex --list-models - /tmp/diffex-local-release/node/diffex -p "Say exactly: ok" - /tmp/diffex-local-release/node/diffex - - # Bun binary smoke tests - /tmp/diffex-local-release/bun/diffex --help - /tmp/diffex-local-release/bun/diffex --version - /tmp/diffex-local-release/bun/diffex --list-models - /tmp/diffex-local-release/bun/diffex -p "Say exactly: ok" - /tmp/diffex-local-release/bun/diffex - ``` - Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/diffex-local-release/node/diffex` and `/tmp/diffex-local-release/bun/diffex` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk. - -3. **Run the release script**: - ```bash - DIFFEX_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:patch # fixes + additions - DIFFEX_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:minor # breaking changes - ``` - Use `npm_config_min_release_age=0` only for the release command. The repo's normal npm age gate can otherwise block the release lockfile refresh when the current workspace package version was published recently. Review any lockfile or shrinkwrap diffs the release creates before push. - - The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed. - -4. **CI verifies and announces the npm release**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required. After publishing, `announce-diffex-release` verifies every public workspace package resolves at the exact release version and that its npm tarball is available, then writes the verified release marker to R2. The release marker must never be published before npm verification succeeds. - -5. **If CI publish or announcement fails**: inspect the failed job. The publish helper is idempotent and skips package versions already present on npm; the announcement job rechecks availability before updating the R2 marker. Rerun the failed job or workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version. - ## User Override If the user's instructions conflict with any rule in this document, ask for explicit confirmation before overriding. Only then execute their instructions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c464a5c3d..2aea6b8a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ npm run build:offline Run the coding agent from source with: ```bash -./diffex-test.sh +./scripts/diffex-test.sh ``` The launcher can be run from any directory and preserves the caller's working directory. @@ -27,7 +27,7 @@ The launcher can be run from any directory and preserves the caller's working di ## Before Making Changes Read [AGENTS.md](AGENTS.md) before changing code. -It defines the repository's source, dependency, testing, Git, and release rules. +It defines the repository's source, dependency, testing, and Git rules. Diffex is a TypeScript monorepo forked from the Pi Agent Harness. Preserve package boundaries and keep the agent core minimal and extensible. @@ -55,11 +55,11 @@ Run the repository checks before requesting review: ```bash npm run check -./test.sh +./scripts/test.sh ``` `npm run check` performs formatting, linting, dependency policy checks, and TypeScript validation. -`./test.sh` runs non-E2E tests without activating suites that depend on provider endpoints or credentials. +`./scripts/test.sh` runs non-E2E tests without activating suites that depend on provider endpoints or credentials. When you create or modify a test file, run that test directly and iterate until it passes. From a package directory, use the repository's Vitest executable with the specific test path. @@ -71,7 +71,7 @@ node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts Do not run the full Vitest suite directly. Some E2E tests activate when endpoint or authentication environment variables are present. -For terminal UI changes, run `./diffex-test.sh` in a controlled terminal and exercise the affected view, dialog, navigation flow, and exit path. +For terminal UI changes, run `./scripts/diffex-test.sh` in a controlled terminal and exercise the affected view, dialog, navigation flow, and exit path. For coding-agent E2E regressions, use the test harness and faux provider under `packages/coding-agent/test/suite`. ## Engineering Expectations diff --git a/README.md b/README.md index 1457e9da0..c0e2c2e29 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@

- diffex.ai wordmark + diffex.ai wordmark

- Daytona Startup Grid + Daytona Startup Grid

- E2B for Startups + E2B for Startups

@@ -76,14 +76,14 @@ git clone https://github.com/diffexai/diffex.git cd diffex npm install --ignore-scripts npm run build:offline -./diffex-test.sh +./scripts/diffex-test.sh ``` Validate changes with: ```bash npm run check -./test.sh +./scripts/test.sh ``` The checks cover formatting, linting, dependency policy, and TypeScript. The test command runs non-E2E suites without activating tests that require provider credentials. diff --git a/RELEASING.md b/RELEASING.md deleted file mode 100644 index 5cdfa3ed6..000000000 --- a/RELEASING.md +++ /dev/null @@ -1,126 +0,0 @@ -# Releasing Diffex - -Diffex publishes only `@diffexai/diffex` and uses GitHub Actions trusted publishing for normal npm releases. -The release tag publishes the approved npm packages and then announces the verified npm release. -Standalone archives and other installation channels are deferred by the [approved distribution policy](docs/release/distribution-policy.json). - -## Prerequisites - -- Use a clean `main` branch with all intended changes merged. -- Confirm GitHub Actions can start jobs and the repository billing or spending limit is not blocking runners. -- Confirm the `@diffexai` npm organization exists and your npm account has package publishing permission with account-level two-factor authentication enabled. -- Use npm 11.15.0 or newer when configuring trusted publishers. -- Confirm every public package has repository metadata pointing to `diffexai/diffex`. - -## Approved payloads - -The single public CLI package is derived from `scripts/release-packages.mjs`. -Internal workspaces remain private and required runtime modules are included inside the CLI tarball. -The generated publication manifest and installation locks contain no registry dependencies on separate Diffex packages. -Every package declares MIT and ships the full root license, including the complete MIT notice for Pi-derived portions. -The generated `NOTICE`, `THIRD-PARTY-NOTICES.txt`, and `distribution-components.json` identify applicable portions and bundled components. -Readable runtime JavaScript, declarations, runtime JSON/SQL data, HTML/CSS templates, and documentation are approved. -Examples and native source build files are excluded; evolution and harness-trace runtime code is explicitly public when vendored in the CLI. -Source maps move into ignored private debug storage, and final tarball inspection rejects embedded maps and prohibited files. -The publisher checks every exact tarball before registry access and publishes those checked bytes. -The same disclosure gate applies to bootstrap publication. - -All nine authorized project changelogs must be regenerated through the owner's `/cl` audit after the one-time reset. -Empty or structurally invalid changelogs block packing. -The pre-reset contents are retained at `ae811d134dd63b0a688aea10ea0368c0935e7e48`; never repeat that reset for later releases. - -The full source archive remains a private build input and is never a public release asset. -The distribution manifest identifies the private source by its full commit string. -The CLI tarball carries `release/distribution-manifest.json`, `release/install-package.json`, and `release/install-package-lock.json`. -Each package carries `distribution-files.json` with SHA-256 hashes for its approved runtime files. -The complete tarball's npm `dist.integrity` is the registry verification value. -Versioned anonymous downloads use `https://registry.npmjs.org//-/-.tgz`. -For the CLI at version 0.2.1, that URL is `https://registry.npmjs.org/@diffexai/diffex/-/diffex-0.2.1.tgz`; it becomes available only after separately authorized publication. -No GitHub release publication or separate asset-repository credential is used. -Npm trust remains bound to the private source repository and the existing workflow filename. - -## First npm publication - -The owner has already verified npm account and organization prerequisites; do not repeat login or ownership checks unless authentication fails. -Use the approved interactive npm session for bootstrap and trust configuration. -npm requires a package to exist before a trusted publisher can be configured. -Use this bootstrap process only once, before the first normal release. - -1. Confirm the current candidate has the owner's `/cl` refresh, approved payload review, and local/native artifact acceptance. - Configure the reviewed `npm-publish` GitHub environment before public bootstrap publication. - See [publication trust and bootstrap evidence](docs/release/publication-trust.md) for the exact settings and outstanding owner decisions. - -2. Build and validate every package without publishing anything. - - ```bash - npm run release:bootstrap-npm:dry - ``` - - For frozen candidate inputs, use the same offline build path as normal CI from a clean committed checkout: - - ```bash - npm run hydrate:model-data - node scripts/release-artifacts.mjs freeze /tmp/diffex-bootstrap-candidate - npm run build:offline - node scripts/release-artifacts.mjs prepare /tmp/diffex-bootstrap-candidate - node scripts/publish.mjs --bootstrap --dry-run --artifacts /tmp/diffex-bootstrap-candidate - ``` - - Retain the entire private directory, including `manifest.json` and frozen source inputs. - Publish or retry these exact reviewed bytes with `node scripts/publish.mjs --bootstrap --artifacts /tmp/diffex-bootstrap-candidate`; do not rebuild them. - A new source commit requires a new reviewed candidate directory before any publication. - -3. Publish the current lockstep version under the non-default `bootstrap` dist-tag. - - ```bash - npm run release:bootstrap-npm - ``` - - Both bootstrap and normal publication explicitly disable provenance because npm does not support it for the private source repository. - Normal CI still authenticates through the exact OIDC trusted publisher; it needs no npm token secret. - npm can create `latest` on first registration despite `--tag bootstrap`. - Bootstrap verifies each newly uploaded artifact and removes only an automatically created `latest` pointing at that exact version, then checks that only the intended bootstrap tag remains. - There can be a registry exposure window between first publication and successful tag removal; retain actual read-back evidence rather than assuming the tag flag prevented it. - An existing `latest` tag blocks bootstrap before uploads. - Failed tag cleanup stops the batch and requires operator inspection of the retained manifest, exact published bytes, and tags before removing the unintended tag with `npm dist-tag rm latest --registry=https://registry.npmjs.org`. - Never remove an established release tag or a tag pointing at another version. - After cleanup or a propagation failure, rerun against the same retained artifact directory without rebuilding. - The command is resumable after a partial failure: it downloads and hashes packages already published at the exact workspace version against the retained approved artifacts and refuses packages that already exist only at other versions. - -4. Run every `npm trust github` command printed by the bootstrap command. - The trusted publisher values must remain: - - - Repository: `diffexai/diffex` - - Workflow file: `build-binaries.yml` - - Environment: `npm-publish` - - Allowed action: `npm publish`; npm also automatically grants `npm stage publish` on relationships created after 2026-09-03. - -5. Verify each package has the expected trusted publisher. - - ```bash - node scripts/npm-publication-trust.mjs --verify - ``` - -6. Restrict traditional token publishing after trusted publishing is verified. - In every package's npm access settings, require two-factor authentication and disallow tokens, then retain the reviewed settings alongside the trust evidence. - Do not revoke unrelated account tokens or claim enforcement from an OIDC configuration alone. - -## Normal release - -Run the appropriate release command only after local checks pass and the release workflow is operational. - -```bash -npm run release:patch -npm run release:minor -``` - -The release command updates versions and generated artifacts, runs checks and tests, creates the release commits and tag, and requires interactive review of generated diffs, and pushes `main` plus the tag atomically. -Only `patch` (fixes and additions) and `minor` (breaking changes) are supported. -Major and explicit-version release commands are rejected. -It is not a dry-run command. -Review the target version and obtain approval for the remote push before running it. - -The pushed tag triggers `.github/workflows/build-binaries.yml`. -Do not announce the release until the workflow verifies npm publication and writes the release announcement successfully. - -See [release promotion and recovery](docs/release/promotion.md) for immutable source selection, retained artifact recovery, and exact failure procedures. diff --git a/docs/cloud-runtime.md b/docs/cloud-runtime.md deleted file mode 100644 index 2626023bc..000000000 --- a/docs/cloud-runtime.md +++ /dev/null @@ -1,53 +0,0 @@ -# Cloud Runtime Boundary - -## Purpose - -The hosted Diffex product runs the same Pi-derived coding-agent runtime as local Diffex. -It does not use the archived Rust implementation or a cloud-specific prompt loop. - -`@diffexai/diffex-server` binds the coding-agent runtime to `@diffexai/diffex-protocol` inside an isolated sandbox. -`@diffexai/diffex-client` connects to that server through a transport supplied by the product. - -## Boundary - -The cloud control plane authenticates users, allocates sandboxes, enforces policy and billing, and issues short-lived attachment URLs. -It authenticates the WebSocket upgrade before relaying any bytes. - -Once connected, the client and in-sandbox server exchange the native length-prefixed CBOR protocol. -The cloud relay preserves byte order, applies backpressure and limits, and does not decode or reinterpret runtime messages. - -The WorkOS access token stays at the control-plane boundary. -Provider credentials, sandbox identifiers, and internal runtime addresses never enter the Diffex wire protocol. - -The production model credential is mounted through Daytona Secrets with a provider-host allowlist. -Only Daytona's opaque placeholder enters the sandbox environment, and the real provider credential is substituted into outbound HTTPS headers outside the sandbox. - -## Runtime Image - -`Dockerfile.runtime` produces a Linux amd64 candidate with a digest-pinned Node base image, compiled workspace outputs, user documentation, and legal notices. -The Runtime Image workflow now permits manual validation and retains the tested image privately. -Registry publication and automatic main-branch publication remain disabled. -The owner confirmed on 2026-09-13 that no staging deployment or Daytona consumer is configured, so no existing production digest or snapshot can be assumed. -See [runtime validation and promotion prerequisites](release/runtime-validation.md) for local commands and the pending hosted acceptance. - -Before a cloud release, register GHCR with Daytona, create a Daytona snapshot from that exact immutable image tag or digest, and set `DAYTONA_SANDBOX_SNAPSHOT` to the resulting snapshot name. -Do not use a floating image tag for production. -The runtime image and the cloud control-plane release must be promoted as one tested release pair. - -## Required Hosted Runtime - -The production runtime service must: - -- Implement `DiffexServerService` using the real coding-agent session APIs -- Persist and reopen durable sessions using stable cloud-provided session IDs -- Expose an authenticated WebSocket listener suitable for a sandbox -- Publish authoritative protocol snapshots from coding-agent state -- Convert runtime events through the existing protocol adapters -- Reject conflicting session mutations instead of silently queuing them -- Shut down cleanly when the sandbox receives a termination signal - -## Compatibility - -The REST control-plane API and the live Diffex protocol are independently versioned. -Changing WorkOS, billing, quota, or sandbox-provider implementation must not change the agent protocol. -Changing the agent protocol must use protocol negotiation rather than an implicit cloud API change. diff --git a/docs/daytona-behaviors-qualification.md b/docs/daytona-behaviors-qualification.md deleted file mode 100644 index ca1a84fe7..000000000 --- a/docs/daytona-behaviors-qualification.md +++ /dev/null @@ -1,96 +0,0 @@ -# Daytona behavior qualification — 2026-10-27 - -## Status - -**Real-service CLI qualification passed. GitHub Actions qualification remains pending.** - -The snapshot, installed package scenarios, live OpenAI transport, intentional assertion failure, overlapping CLI runs, active cancellation and runner-loss recovery were exercised against Daytona. This is not evidence that the GitHub workflow or its 30-day artifact retention has executed successfully. - -At qualification time, remote `main` was `c79aa4ccf251507c39cbc132353231cc08ca01fe` and did not contain `daytona-behaviors.yml`. The user's no-push restriction was retained. `DAYTONA_BEHAVIORS_QUALIFIED` remains `false`; automatic spending has not been enabled. - -## Inputs - -| Input | Identity | -| --- | --- | -| Frozen candidate source | `d025b29e554c13c4835a2d592adf47c39d49b752` | -| Package | `@diffexai/diffex@0.4.0` | -| Reviewed tarball SHA-256 | `da75c4042adbc0146b213682cb2a2477a5f1c97c6853b7179557648b70b47b92` | -| Corrected scenario harness | `6efd24323c7d14bb85e2dfe62a774f85c7643a4a` | -| Corrected harness bundle SHA-256 | `d5b57d1e15f1b39e1c0734747b7080a4c590a8dea6e34461816f02415a4df29e` | -| Publication guard | `805454eb6` | -| Snapshot | `diffex-behaviors-v1-e94e1af7cbc7` | -| Snapshot ID | `2a851315-6915-40e8-9b38-0446b7d329f5` | -| Dockerfile SHA-256 | `e94e1af7cbc73ce2b1a9b85e79974f73a39d69f9187f58daeb885bed90fa5f3a` | - -The candidate was frozen, built offline, packaged and verified once. Corrected harness bundles reused those retained candidate bytes rather than silently rebuilding or replacing a failed candidate. Candidate and harness commits are recorded separately; the corrected harness is not represented as part of the older frozen source commit. - -The real sandbox reported Node `22.23.2`, npm `11.16.0`, Bun `1.3.11`, Python `3.11.2`, tmux `3.3a`, ripgrep `13.0.0` and fd `8.6.0`. Snapshot and sandbox resources were checked as 2 vCPU, 4 GiB RAM and 8 GiB disk. Creation requests used the 45-minute wall-clock TTL. - -## Successful installed-package runs - -Both modes used the same retained tarball and corrected harness bundle. - -| Run directory | Result | Evidence | -| --- | --- | --- | -| `deterministic-v4/` | All 13 scenarios passed | npm and Bun installation/inventory checks; help/version/models/resolution; print; built-in OpenAI API-key transport fixture; real read/write/edit/bash; cancellation/recovery; clean exit; persisted-session resume; 80×24, 120×40 and resize baselines; existing artifact login/terminal acceptance | -| `live-v3/` | All 3 scenarios passed | Retained artifact/toolchain integrity, npm installation integrity, real OpenAI print and interactive read/write task | - -The live run used `openai/gpt-4.1-mini`, made **4 provider requests**, produced **98 output tokens** total (maximum **38** in one request), and reported approximately **$0.00205** provider usage. The harness capped every request at 512 output tokens and the smoke at three minutes after installation. It observed 41 streamed text events, successful real `read` and `write` tools, exact input/output file bytes and application exit code 0. - -The reported cost is for the final passing live run, not an account billing total. Earlier failed or superseded harness runs remain retained separately. - -## Failure and lifecycle rehearsals - -- **Intentional assertion failure:** `intentional-failure/` used a separately identified bundle with a deliberately wrong 80×24 baseline. Both package-manager terminal scenarios failed with `Layout differs: startup-80x24.txt`; the runner exited nonzero, retained JSON/JUnit/captures, and confirmed deletion. The approved bundle was not modified. -- **Two overlapping runs:** `overlap-a/` and `overlap-b/` had simultaneous sandbox lifetimes, distinct sandbox IDs and ownership labels, successful complete deterministic results, separate evidence and confirmed deletion. These were overlapping CLI invocations, not GitHub workflow runs. -- **Active cancellation:** `cancel-active/` received SIGTERM after remote execution began and before completion. It remained failed, explicitly reported incomplete final results, and confirmed deletion. Missing results did not become a pass. -- **Runner loss and recovery:** the initial `live-v1/` and `deterministic-v2/` supervisors were killed after an evidence-transport incompatibility was diagnosed. `recover` retrieved completed remote reports without relaunching tests, preserved their failed assertions and confirmed deletion. -- **Independent final audit:** fresh lookups by sandbox ID and name, plus ownership-label queries, confirmed that **all 10 sandboxes created during this qualification were deleted**. The reusable toolchain snapshot is intentionally retained. - -Fake-client tests additionally cover ambiguous creation, unavailable lookups, upload/input failures, ownership mismatch, cleanup failures, corrupt or missing evidence, invalid JUnit and interruption. These tests are not represented as real Daytona service-outage rehearsals. - -## Issues discovered and fixed - -Failures were preserved; assertions were not automatically rerun. - -1. The SDK returns a string-backed enum for snapshot state. Stringifying it produced `SnapshotState.ACTIVE` rather than `active`; readiness now compares the actual enum value correctly. -2. SDK `0.226.1` rejects streaming downloads of empty files. The runner now materializes an empty file locally only after verifying its declared zero size and SHA-256. Nonempty evidence still uses streaming downloads. -3. Polling tolerated changed log bytes but final collection did not previously require completeness. Final evidence now requires complete size/hash verification, matching JSON identity/evidence references, and parsed JUnit with matching scenarios and outcomes. -4. The sandbox's tmux could report a dead pane without a usable pane exit status. A PTY-preserving launcher now independently records the actual application child's return code; zero exit and a dead pane are both required. This does not replace the application's terminal, session or UI implementation. -5. The first live model copied the marker but omitted its trailing newline. The fixture now contains an unambiguous marker with no trailing newline; byte-for-byte output verification remains required. -6. Actions artifacts in this public repository are not confidential. A fail-closed publication guard now scans every evidence file, including launcher/install logs, before publishing a separate sanitized tree. Credential findings fail the producing job; unexpected guard failures block upload entirely. Authentication files and symlinks are excluded. Hidden evidence files are retained. - -## Evidence and secret handling - -Local evidence roots, relative to the repository: - -```text -.artifacts/daytona-qualification/ -.artifacts/daytona-qualification-final-sanitized/ -``` - -Useful records include: - -- `qualification-summary.json` -- `preparation-report.json`, `SHA256SUMS` and `logs/` -- `snapshot-v1/snapshot.json` -- `deterministic-v4/{state.json,orchestration.json,orchestration.xml,evidence/}` -- `live-v3/{state.json,orchestration.json,orchestration.xml,evidence/}` -- `rehearsals.json` and `interruption-rehearsal.json` -- `independent-deletion-audit.json` -- The sanitized root's `publication-safety.json` - -The final publication guard scanned **955 files** and found **zero exact matches** for either real credential. Keys were loaded privately from the owner's external credential file, never placed in command arguments, printed or committed. The Daytona key stayed outside sandboxes; only live execution received the OpenAI key. GitHub stores both as encrypted, main-only environment secrets. - -These ignored local directories are not a 30-day GitHub retention guarantee. No qualification evidence was uploaded through Actions because the workflow was not present on remote `main`. Treat any future Actions evidence as non-confidential sanitized data, not a secret store. - -## Remaining GitHub boundary - -After explicit authorization to push the reviewed commits, follow [the operator procedure](daytona-behaviors.md#qualification-and-enabling-automatic-runs): - -1. Push through the repository's normal review/protection process; do not bypass branch protection. -2. Dispatch the workflow on its exact remote `main` commit and require both modes plus aggregate to pass with 30-day artifacts. -3. Rehearse workflow cancellation and two overlapping workflow runs; confirm per-run evidence retention and sandbox deletion. -4. Review the retained results before setting `DAYTONA_BEHAVIORS_QUALIFIED=true`. - -A successful CLI qualification does not authorize skipping those GitHub checks or enabling the qualification variable early. diff --git a/docs/daytona-behaviors.md b/docs/daytona-behaviors.md deleted file mode 100644 index 25eda3ad0..000000000 --- a/docs/daytona-behaviors.md +++ /dev/null @@ -1,323 +0,0 @@ -# Daytona installed-artifact behaviors - -## Status and scope - -The workflow and harness are implementation, not remote qualification evidence. -A maintainer must provision Daytona access, prepare a snapshot, supply an approved live-provider credential and run the qualification below before enabling automatic runs. -Local fixture tests, SDK mocks and workflow validation do not establish that Daytona creation, upload, PTY execution, live inference or deletion works against the real service. -Do not mark the integration qualified until those operations and their failure paths have retained evidence. - -The [2026-10-27 qualification record](daytona-behaviors-qualification.md) documents successful real-service CLI runs and lifecycle rehearsals. GitHub Actions execution, retention and workflow-level cancellation/overlap qualification remain pending; automatic execution is still disabled. - -This is a standalone acceptance gate for the packaged Diffex CLI, not the hosted product's runtime image, a release publisher or a replacement for native artifact acceptance. -It installs the exact reviewed candidate tarballs into isolated consumers outside the repository. -The deterministic mode controls inference while exercising installed behavior; the live mode uses a bounded real OpenAI request path. -Npm behavior consumers explicitly enable dependency lifecycle scripts; Bun consumers retain the scripts-disabled path. -A separate deterministic `npm-global-installation` scenario checks normal global npm installation of both the exact candidate tarball and the currently published CLI, each in its own fresh isolated prefix and cache. -A passing deterministic run is not live-provider evidence. -The snapshot is toolchain-only; each run uploads its candidate artifacts and checked harness rather than baking repository source or credentials into a snapshot. -Version 1 covers the Linux installed CLI only; hosted-runtime behavior and subscription/OAuth login qualification are out of scope. -The workflow performs no automatic reverts and changes no release or publication gates. - -## Workflow contract - -`.github/workflows/daytona-behaviors.yml` listens to every push to `main` and to manual dispatch. -There are no path filters or concurrency cancellation: overlapping pushes retain independent run IDs and sandboxes. -Every checkout explicitly uses `github.sha`, including a manually dispatched run's selected commit. -A later movement of `main` must not change the source under test. - -Automatic pushes are gated by the **repository variable** `DAYTONA_BEHAVIORS_QUALIFIED` being exactly `true`. -Leave it unset or `false` until qualification passes; unqualified pushes skip the gated jobs rather than reporting remote success. -Manual dispatch bypasses this variable so qualification and repair remain possible. -The workflow never sets its own qualification variable. - -1. **Prepare, once, with a 35-minute timeout.** Install locked dependencies with lifecycle scripts disabled, hydrate model data once, then run the existing artifact pipeline: - - ```sh - npm run hydrate:model-data - node scripts/release-artifacts.mjs freeze .artifacts/release - npm run build:offline - node scripts/release-artifacts.mjs prepare .artifacts/release - node scripts/release-artifacts.mjs verify .artifacts/release - git diff --exit-code HEAD - ``` - - This preserves the frozen source/model inputs, manifests and exact reviewed npm tarballs. - There is no second candidate build or repack in either behavior job. - Preparation also runs the targeted Node fixture/workflow tests and Python fake-client/report tests without credentials. - The separate harness bundle contains only: - - ```text - scripts/artifact-installation.mjs - scripts/npm-lifecycle-registry.mjs - scripts/verify-candidate-artifacts.py - scripts/fixtures/artifact-handoff.mjs - scripts/daytona-behaviors/installed.py - scripts/daytona-behaviors/install.mjs - scripts/daytona-behaviors/npm-global-install.mjs - scripts/daytona-behaviors/fixture.mjs - scripts/daytona-behaviors/live-guard.mjs - scripts/daytona-behaviors/baselines/ - ``` - - Bundle paths start at `scripts/`; no package source tree is needed. - The workflow retains a `SHA256SUMS` sidecar and verifies the downloaded bundle before execution. - -2. **Deterministic and live, in separate jobs.** Both depend only on prepare and use the GitHub environment `daytona-behaviors`. - Each downloads the same retained release and bundle, re-verifies them and creates its own sandbox. - Each job has a 35-minute timeout, a 25-minute execution step, a 1,200-second runner deadline and a separate bounded recovery step. - A run ID includes the workflow run ID, attempt and mode, so retry evidence cannot be confused with an earlier attempt. - -3. **Recovery and evidence, including failure.** Once execution starts, the always-run recovery step invokes `recover --output DIR`. - Recovery retrieves available evidence and confirms cleanup; it does not launch or retry behavior execution. - The workflow records execution and recovery outcomes separately in `workflow-job.json`, then always runs the final all-files credential publication guard before any per-mode evidence upload. - The guard scans the entire local output root, including `installed.log`, `launcher.log`, nested `install.txt` files, binary captures and hidden files; it does not rely on the installed suite's earlier filtering. - It reads actual credential bytes only from `DAYTONA_API_KEY` (both modes) and `OPENAI_API_KEY` (live only) in its environment, never from credential files or command-line arguments. - Auth files/directories, symlinks and special files are excluded without opening them. - Exact credential bytes in file contents and path names are redacted before a separate sanitized publication copy is written; the original local recovery output is never uploaded. - `publication-safety.json` records only sanitized paths, credential variable names, counts and hashes of sanitized bytes, not credential values or hashes of original secret-bearing bytes. - A credential match fails the guard and producing job even if the sanitized evidence can be retained for diagnosis. - Only a fully completed scan, final byte/path verification and safety report set `safe_to_upload=true`; unexpected failures or missing scan credentials block upload rather than falling back to raw evidence. - These exact-byte checks are defense in depth, not a general detector for arbitrary credentials or encoded secret transformations: the harness must never write secrets to evidence or logs. - A recovered or sanitized transcript does not turn a failed run into a pass. - -4. **Aggregate, including missing results.** The final job runs even when dependencies fail or skip on an enabled run. - It requires successful prepare, deterministic and live jobs; exact commit/mode/run-ID markers; successful execution and recovery; passing orchestration JSON with confirmed deletion; the complete expected installed scenario inventory; matching candidate tarball hashes in both modes; and nonempty orchestration and installed-suite XML reports. - Missing artifacts, incomplete results, execution failure, credential-guard failure or cleanup failure fail the aggregate. - The aggregate requires a clean publication safety report and checks the producing jobs' outcomes, so a failed sanitizer cannot be hidden by otherwise passing scenario reports. - Evidence-upload failure also leaves the producing job failed. - Aggregate validation does not echo evidence JSON or credential values into job logs or the step summary. - Per-mode and aggregate uploads include hidden sanitized files, preserving `.orchestration-exit` and other digest-manifest members; excluded auth files stay excluded. - -GitHub artifacts retain the release, bundle, per-mode evidence and aggregate evidence for **30 days**. -Download evidence before expiry if a longer-lived qualification record is required. -This repository is public: GitHub Actions artifacts can be downloaded by authenticated users with repository read access and are **not confidential storage**. -The release and bundle artifacts contain no credentials; per-mode and aggregate evidence must contain only the guarded, sanitized publication copies. -They are workflow artifacts, not npm or release assets; that distinction does not make them private. -Never rely on GitHub log masking or artifact access controls to protect keys. - -## Normal npm installation validation - -The `npm-global-installation` scenario runs the advertised `npm i -g @diffexai/diffex` command without `--ignore-scripts`, after verifying that isolated npm configuration has `ignore-scripts=false`. -The separate candidate installation serves its retained tarball through a loopback-only registry, not an unpublished public registry version. -The fixture advertises npm's `_hasShrinkwrap` metadata only after validating the embedded lock. This matches published-package resolution: npm 10 can ignore a nested shrinkwrap when the dependency is supplied directly as a local tarball. -Synchronous local npm consumers seed their root installation lock from the same verified candidate shrinkwrap, including exact archive integrity, rather than letting a file-based install re-resolve reviewed dependency ranges. -The public installation uses the independently verified public npm archive: a candidate and the public package may share a version while having different bytes, so their inventories must never be interchanged. -Public metadata and tarball fetches reject redirects, enforce bounded reads, and verify SHA-512 registry integrity before installation; the public latest identity must remain unchanged across the run. -Each installation checks its own expected version, help, models, executable resolution through PATH, and installed runtime file hashes against its own pre-install archive inventory. -The report records script policy, cold-cache state, package identities, command outcomes, successful execution of both exact reviewed dependency hooks, and confirmed temporary-prefix cleanup. -Hook evidence is extracted from isolated npm debug logs as reviewed package/version/hook/exit tuples; raw logs, hook output, auth files and configuration are not copied into the report. -The global validator shares a 600-second deadline across downloads and commands, terminates active command process groups on timeout or cancellation, and cleans prefixes before returning. -Its Python supervisor allows 630 seconds, then sends SIGTERM to permit validator cleanup before a bounded SIGKILL fallback; a supervisory timeout remains a failure. -Missing entries, disabled scripts, hash mismatches or failed cleanup fail the installed scenario and aggregate. -The complete deterministic/live behavior suite still exercises the candidate rather than substituting public release behavior for candidate coverage. - -This is validation infrastructure, not a record that the changed workflow has passed remotely. -Public installation guidance uses the ordinary npm command, with scripts-disabled installation retained as an option. Local command validation is not remote Daytona qualification: do not enable automatic runs until the qualification below is complete. -Preparation, publishing and self-updates continue to disable lifecycle scripts. Native and Daytona npm candidate consumers explicitly opt into reviewed hooks; pnpm and Bun consumers remain scripts-disabled. This does not authorize enabling scripts throughout CI. - -## Configure access and a snapshot - -A repository administrator and a Daytona account owner must perform this setup. -Do not put API keys in the Dockerfile, bundle, checked files, command-line arguments or repository variables. - -Create the GitHub environment `daytona-behaviors` and configure: - -| Setting | Location | Purpose | -| --- | --- | --- | -| `DAYTONA_API_KEY` | Environment secret | Local orchestration calls to Daytona; never passed as the sandbox's model credential. | -| `OPENAI_API_KEY` | Environment secret | Approved, restricted live-provider key; injected only into live execution and the live publication guard. | -| `DAYTONA_BEHAVIORS_SNAPSHOT` | Environment variable | Prepared toolchain snapshot name passed explicitly as `--snapshot`. | -| `DAYTONA_BEHAVIORS_QUALIFIED` | Repository variable | Set to `true` only after reviewing a successful qualification. | - -The deterministic job and recovery steps do not receive `OPENAI_API_KEY`. -Each publication guard receives only its mode's credential whitelist, scoped to that step; credential values never appear in its arguments, report or status output. -The prepare and aggregate jobs receive neither credential. -Provider credentials must be test-only, revocable and covered by a spending limit; do not use personal login state or production agent configuration. -Review the current model, request/token/time limits and allowed provider destination in the checked live harness before approval. -Its application-level guard is not a claim of an account-wide network firewall. - -Install the pinned orchestration SDK in a local virtual environment: - -```sh -python3 -m venv /tmp/diffex-daytona-venv -/tmp/diffex-daytona-venv/bin/python -m pip install -r scripts/daytona-behaviors/requirements.txt -``` - -`requirements.txt` pins `daytona==0.226.1`. -Supply `DAYTONA_API_KEY` through an approved local secret mechanism, then prepare the snapshot from the checked `scripts/daytona-behaviors/Dockerfile`: - -```sh -/tmp/diffex-daytona-venv/bin/python scripts/daytona-behaviors/daytona_runner.py prepare-snapshot \ - --name diffex-behaviors-toolchain-v1 \ - --output .artifacts/daytona-snapshot -``` - -Inspect the preparation evidence and the resulting snapshot's readiness in Daytona before setting `DAYTONA_BEHAVIORS_SNAPSHOT`. -Record the checked Dockerfile commit, snapshot name/identity, architecture, actual Node/npm/Bun and helper versions, preparation outcome and owner. -An existing production snapshot must not be assumed suitable. -If the toolchain changes, prepare and qualify a new snapshot rather than silently changing an approved baseline. -Snapshot preparation is an explicit operator action, not part of each push. -The versioned definition pins Node **22.23.2**, npm **11.16.0** and Bun **1.3.11**, and installs Python, tmux, ripgrep and fd. -Each sandbox uses **2 vCPU, 4 GiB RAM and 8 GiB disk**; the runner checks snapshot and sandbox resources and verifies installed toolchain versions. -A **45-minute wall-clock TTL** (`ttl_minutes=45`) destroys the sandbox independently of activity or state; stopped-time auto-delete is not a substitute. -See [Daytona wall-clock TTL](https://www.daytona.io/docs/en/sandboxes/#wall-clock-ttl). -The live scenario defaults to `openai/gpt-4.1-mini`, with a shared six-request budget across print/TUI processes, 512 output tokens per request and a three-minute smoke deadline after installation. -Assertions are never automatically rerun. - -Restrict the `daytona-behaviors` environment's deployment branch policy to **branch `main` only**; do not allow other branches or tags to access these secrets. -Qualification dispatches must therefore run from `main` after the reviewed workflow lands there through the normal review process. -The implementation does not push or merge itself. -After qualification, protection rules must permit trusted `main` pushes without required manual approval if fully automatic execution is intended. -Do not broaden the deployment branch policy or remove security controls without administrator review; a workflow waiting for environment approval has not executed remotely. - -## Runner interface and local reproduction - -Use retained verified artifacts rather than rebuilding a failing candidate. -Extract the release artifact and behavior-bundle artifact into the same directory layout used by the workflow, then verify: - -```sh -node scripts/release-artifacts.mjs verify .artifacts/release -sha256sum -c .artifacts/daytona-bundle/SHA256SUMS -``` - -Artifact verification requires a checkout of the matching source commit and its unchanged lockfile and distribution policy. -`DIFFEX_SOURCE_COMMIT` may be set to enforce the expected commit explicitly. - -Run one mode with a fresh output directory and unique run ID: - -```sh -/tmp/diffex-daytona-venv/bin/python scripts/daytona-behaviors/daytona_runner.py run \ - --artifacts .artifacts/release \ - --bundle .artifacts/daytona-bundle/bundle.tar.gz \ - --snapshot diffex-behaviors-toolchain-v1 \ - --output .artifacts/daytona-deterministic \ - --mode deterministic \ - --commit "$(git rev-parse HEAD)" \ - --run-id "manual-$(date +%s)-$$-deterministic" -``` - -`--bundle` accepts a bundle directory or a tar.gz archive. -For a live run use `--mode live`, a separate output directory/run ID and the approved `OPENAI_API_KEY` environment secret. -The Daytona API key belongs only on the orchestration machine; it must not be supplied as an in-sandbox environment variable. - -Expected local output: - -- `state.json`: durable identity written before sandbox creation, used for recovery. -- `orchestration.json` and `orchestration.xml`: orchestration outcomes and cleanup evidence. -- `evidence/results.json` and `evidence/results.xml`: incremental installed-suite results. -- Available per-scenario transcripts, events, terminal captures/baseline comparisons and other diagnostic files produced by the harness. -- `workflow-job.json`: CI-only record of exact source, run identity, execution and recovery outcomes. -- `publication-safety.json`: final guard report in the separate sanitized publication directory, never an authorization to upload the original output. - -Do not delete or hand-edit `state.json` while a sandbox may exist. -For interrupted execution, retained output or cleanup failure: - -```sh -/tmp/diffex-daytona-venv/bin/python scripts/daytona-behaviors/daytona_runner.py recover \ - --output .artifacts/daytona-deterministic -``` - -Recovery must not create a replacement sandbox or re-execute tests. -It must preserve failure evidence and positively confirm deletion; an attempted delete is not enough. -If credentials, runner termination or service failure prevent confirmation, treat cleanup as failed and use the saved sandbox/run identity with the Daytona owner to reconcile resources. -CI's always-run step is best effort under cancellation or runner loss, not a guarantee that GitHub will execute cleanup. -Recover manually and confirm no run-owned sandboxes remain before closing the incident. - -Before sharing any local evidence, run the same publication guard after recovery with the approved credentials available only in the environment: - -```sh -python3 scripts/daytona-behaviors/sanitize_evidence.py \ - --root .artifacts/daytona-deterministic \ - --output .artifacts/daytona-deterministic-sanitized \ - --mode deterministic -``` - -Use `--mode live` and a separate fresh publication destination for live evidence. -A nonzero exit is a security failure, even if a completed sanitized copy and its safety report exist. -If the scan did not complete, do not publish any evidence; never substitute the raw directory. -Retain or share only a completed sanitized copy, review it for unrelated sensitive data and treat it as public-readable. - -## Local executable checks and baseline review - -To exercise an already installed executable without creating a Daytona sandbox: - -```sh -python3 scripts/daytona-behaviors/installed.py \ - --executable /path/to/installed/node_modules/.bin/diffex \ - --output /tmp/diffex-local-behaviors \ - --mode deterministic \ - --commit "$(git rev-parse HEAD)" -``` - -Use a fresh output directory and the required local Node, tmux and search-helper toolchain. -This executable-only mode does not establish reviewed-tarball installation integrity, both package-manager paths or remote qualification. -For an intentional local baseline refresh, append `--update-baselines` to that command. -Only a local reviewer may regenerate baselines, inspect the raw and normalized terminal captures, review the baseline diff and submit intentional changes through normal review. -CI never passes `--update-baselines` and must fail rather than automatically accepting a changed layout. - -## Qualification and enabling automatic runs - -Qualification requires a user with Daytona access and approved provider spending authority. -The implementation must remain **unqualified** until that user supplies and reviews real evidence. - -1. Leave `DAYTONA_BEHAVIORS_QUALIFIED` unset or `false`; configure the main-only environment and prepare the toolchain snapshot. - Wait until the reviewed workflow and harness land on `main`; these instructions do not authorize a push or merge. -2. Dispatch a passing qualification run from `main`: - - ```sh - gh workflow run daytona-behaviors.yml --ref main - ``` - - Record the resolved 40-character commit and workflow run URL; the workflow checks out that event's SHA, not a later branch tip. - Require successful deterministic **and** live results against the same retained candidate hashes, the complete expected behavior inventory and reviewed terminal baselines. - Confirm the live path actually reached the approved provider rather than a fixture. - Verify provider request/token/time limits and that neither evidence nor logs contain credentials or unrelated user state. -3. Require an **intentional product-assertion failure**, not just a missing credential or transport error. - An operator can copy the retained harness into a separate local bundle, deliberately corrupt one expected terminal baseline and run the deterministic remote CLI against the unchanged retained candidate: - - ```sh - bad_bundle="$(mktemp -d /tmp/diffex-daytona-bad-baseline.XXXXXX)" - tar -xzf .artifacts/daytona-bundle/bundle.tar.gz -C "$bad_bundle" - printf 'intentional qualification mismatch\n' > "$bad_bundle/scripts/daytona-behaviors/baselines/startup-80x24.txt" - /tmp/diffex-daytona-venv/bin/python scripts/daytona-behaviors/daytona_runner.py run \ - --artifacts .artifacts/release --bundle "$bad_bundle" \ - --snapshot diffex-behaviors-toolchain-v1 \ - --output .artifacts/daytona-intentional-failure --mode deterministic \ - --commit "$(git rev-parse HEAD)" --run-id "failure-$(date +%s)-$$" - ``` - - Expect a nonzero exit and a recorded baseline assertion failure, retained JSON/XML and captures, and confirmed sandbox deletion. - Run `recover --output .artifacts/daytona-intentional-failure` if needed; it must not relaunch or turn the failed execution into a pass. - Record the deliberately altered bundle identity separately; never replace the original retained bundle, regenerate its approved checksum or commit the bad baseline. - Run the final publication guard on the local CLI output and retain only its completed sanitized copy with the qualification record; this rehearsal needs no new workflow inputs. -4. Require a **cancellation rehearsal**: dispatch another run from `main`, wait until a sandbox and behavior execution exist, then cancel that workflow run. - Retain the run URL, sandbox/run identities and whatever incremental artifacts were uploaded. - Recover available saved output without relaunching; if cancellation prevented artifact upload, use the recorded run labels and the Daytona owner to reconcile resources. - Confirm deletion of every sandbox created by that run; missing evidence or an attempted delete alone does not satisfy qualification. -5. Require **two overlapping workflow runs**: dispatch twice from `main` without waiting for the first to finish, and verify that both actually execute concurrently rather than queue behind environment approvals. - For each run separately, verify the resolved source commit, unique run/attempt/mode identities, release and bundle artifacts, deterministic/live results, aggregate evidence and confirmed deletion of every owned sandbox. - Confirm that neither run cancels the other, overwrites the other's artifacts, mixes evidence or deletes the other's active sandbox. -6. Also exercise creation/upload/execution/download failures, missing/incomplete result evidence and cleanup failure through operator-controlled remote rehearsals. - Retain proof that failures stay failed, recovery does not relaunch and deletion is positively confirmed. - Local mock tests are supplementary, not substitutes for these remote rehearsals. -7. Review all passing, intentional-failure, cancellation and overlapping-run evidence, snapshot/toolchain identities, sandbox IDs and cleanup confirmations. - Keep a qualification record with owner, date, commits, workflow run URLs, snapshot, artifact/bundle hashes, provider/model, bounded cost and failure-path evidence. - Ensure no orphaned run-owned sandboxes remain. -8. Only after those checks pass, an administrator enables subsequent pushes: - - ```sh - gh variable set DAYTONA_BEHAVIORS_QUALIFIED --body true - ``` - - Observe the next `main` push and require both modes plus aggregate to execute automatically. - Setting the variable does not retroactively execute skipped pushes. - -To suspend automatic spending without removing dispatch or evidence: - -```sh -gh variable set DAYTONA_BEHAVIORS_QUALIFIED --body false -``` - -Requalify after material snapshot, SDK, credential policy or harness changes. -Neither a static workflow test nor setting the variable proves remote qualification. diff --git a/docs/dependency-maintenance.md b/docs/dependency-maintenance.md deleted file mode 100644 index 1adfb893e..000000000 --- a/docs/dependency-maintenance.md +++ /dev/null @@ -1,60 +0,0 @@ -# Dependency maintenance - -Direct external dependencies are pinned to exact versions. Refresh the root lockfile with `npm install --package-lock-only --ignore-scripts`, hydrate with `npm install --ignore-scripts`, then regenerate the CLI distribution locks: - -```bash -node scripts/generate-coding-agent-shrinkwrap.mjs -node scripts/generate-coding-agent-install-lock.mjs -npm run check -./test.sh -``` - -Keep each dependency's pins synchronized across consuming workspaces and examples. Update live documentation examples when their dependency versions change; do not rewrite historical release records or notices for unchanged vendored files. The HTML exporter still vendors marked 18.0.5 and highlight.js 11.9.0; packaging reads their tracked licenses under `legal/upstream/`, independently of installed runtime versions. - -## Compatibility exceptions - -- Vitest and `@vitest/coverage-v8` use **4.1.12**, the patched 4.x release. The latest `vitest-evals` (**0.17.0**) requires `vitest >=4 <5`, so Vitest 5 is not compatible with the evaluation workspace. -- `hosted-git-info` remains **9.0.3**. Version 10.1.1 requires Node `^22.22.2 || ^24.15.0 || >=26.0.0`, above the project's Node 22.19.0 minimum. -- `@typescript/native-preview` has no stable release under that package name; do not replace its existing pin with another prerelease as part of a stable-only update. Its existing pin remains available to workspace build scripts, while the root check uses stable TypeScript 7's `tsc`. - -## Install tooling - -npm 10.9.3 can crash while resolving optional Vitest peers (`Cannot read properties of null (reading 'edgesOut')`). npm **11.21.1** supports the project's minimum Node version and can be invoked temporarily without changing the global installation: - -```bash -npm exec --yes --ignore-scripts --package=npm@11.21.1 -- npm install --package-lock-only --ignore-scripts -``` - -If changed dependencies remain duplicated under workspaces, run `npm dedupe --package-lock-only --ignore-scripts` before regenerating distribution locks. The generators resolve dependencies from their originating workspace and relocate them to published `node_modules/` paths, preserving nested version differences and platform-specific optional dependencies. They reject unresolved or conflicting flattened dependencies. The root OpenTelemetry API pin also makes SDK optional peers available to the browser smoke check. - -The repository normally enforces a two-day minimum release age. Do not disable it without explicit approval. This update batch received approval to use `npm_config_min_release_age=0` on install commands only; `.npmrc` remains unchanged. - -## Development tooling - -Development tooling uses Biome **2.6.3**, TypeScript **7.0.2**, esbuild **0.28.2**, and tsx **4.23.15**. Biome's schema and recommended preset configuration are migrated together with newly enforced diagnostics. The TypeScript target is **ES2024**, matching Unicode-set regular expressions already used on supported Node runtimes. The relative-import policy checker uses TypeScript 7's exported native AST API (`typescript/unstable/ast` and `typescript/unstable/sync`); these API paths are unstable, so the exact compiler pin and syntax/diagnostic regression tests must move together. Temporary parser projects and native clients are cleaned up after every check. - -`diff` supplies its own declarations, so the obsolete `@types/diff` stub is no longer needed. The rimraf override is synchronized at **6.1.3** in the root and published CLI manifests/locks. Nanoid and protobufjs overrides remain in their consumers' compatible major lines rather than forcing unsupported transitive majors. - -## Runtime libraries - -Runtime pins use keyring **2.1.2**, Chalk **6.0.1**, diff **9.0.0**, highlight.js **11.12.0**, and HTTP/HTTPS proxy agents **9.1.0**. Terminal tests use xterm/headless **6.0.0**. Node declarations are synchronized at **26.6.5**; the supported runtime minimum remains Node **22.19.0**, and no new runtime APIs are required. - -Keyring's missing-password result is now `null`. The storage adapter still reports a missing session as `undefined`. Socket adapters normalize the new declaration's string-or-buffer input, and syntax highlighting uses highlight.js's public entry point. The extension dependency example pins Chalk 6.0.1. - -The standalone sandbox example lock now resolves shell-quote **1.12.0**, addressing both reported shell-quote advisories. Sandbox runtime itself is intentionally retained at **0.0.26**: version 0.0.81 introduces node-forge 1.4.0 with the unpatched [GHSA-86w9-cpqp-85rv](https://github.com/advisories/GHSA-86w9-cpqp-85rv) signature-verification advisory. Revisit this exception when a patched upstream dependency is available; do not force an incompatible Zod major override to suppress the remaining audit findings. - -## Networking review - -[Undici 8.12.3](https://github.com/nodejs/undici/releases/tag/v8.12.3) fixes HTTP/2 connection replacement following stream timeouts and preserves caller async context for queued pool requests. These can affect timeout recovery and request attribution; review networking tests when updating. It retains the Node 22.19.0 minimum. - -## Provider SDK migrations - -The provider SDKs use Anthropic **1.3.0**, Google GenAI **2.31.0**, Mistral **3.2.0**, and OpenAI **7.43.0**. The standalone custom Anthropic provider example uses the same Anthropic pin and lockfile. Mistral uses scoped Chat and HTTPClient imports so its new Node-only voice exports do not enter browser bundles. Cloudflare compatibility tests inject an in-memory fetch implementation rather than relying on SDK hoisting. - -Google automatic continuation is explicitly disabled for Gemini and Vertex requests. A continuation limit is reported as `length` rather than allowing the SDK to send additional requests automatically. Google authentication remains independent of GitHub credentials. - -Anthropic adopts the SDK's API version **2026-10-21** and no longer sends the removed sampling parameters. A model-context-window limit is reported as `length`. The Google GenAI install-script allowlist was reviewed and updated: its published `preinstall` is still a no-op, and installs continue to skip scripts. - -## Security review - -The initial audit reported seven affected packages, all present in the baseline lockfile. Updating Vitest/coverage to 4.1.12 and refreshing compatible Vite and source-map-js transitives removes five findings. The remaining sandbox-runtime/Zod dependency chain requires further review; do not force a Zod major-version override across consumers without checking their APIs. diff --git a/docs/identity-handoff.md b/docs/identity-handoff.md deleted file mode 100644 index a62094872..000000000 --- a/docs/identity-handoff.md +++ /dev/null @@ -1,57 +0,0 @@ -# Product identity handoff - -The current Diffex identities and package map are recorded below. -The [candidate handoff](release/candidate-handoff.md) preserves historical acceptance evidence; its service and attribution approvals are superseded by current policy. -The canonical source remains private. -No package, image, release asset, or source archive is published by this change. - -| Identity | Accepted value | -| --------------------------- | -------------------------------------------------------------------------------------------------------------- | -| Source repository | `diffexai/diffex` | -| npm scope and canonical CLI | `@diffexai`, `@diffexai/diffex` | -| Executable | `diffex` | -| Hosted runtime image | `ghcr.io/diffexai/diffex-runtime` | -| Product author and website | DiffexAI, `https://diffex.ai/` | -| Legal copyright owner | Luntraa, Inc. | -| Release API | `https://api.diffex.ai/api/latest-version`, owned by `diffex-cloud` | -| Model catalog API | `https://api.diffex.ai/api/models/providers/{providerId}`, owned by `diffex-cloud` | -| Radius services | Removed; prohibited in development, testing, evaluation, and production | -| Security contact | Existing private access to a Diffex repository owner; no dedicated mailbox or public reporting channel claimed | -| Issue authorization | Write/admin permission on the current Diffex repository | - -Diffex and Luntraa, Inc. are unrelated to Earendil. -The former staff-team authorization, security mailbox reference, announcement, and product affiliation wording have been removed. -Provider attribution now identifies Diffex; see [provider validation](release/provider-branding-validation.md). -ChatGPT now registers Diffex and uses public model and inference APIs; existing inherited registrations require a new sign-in as described in [provider setup](../packages/coding-agent/docs/providers.md#chatgpt). -The xAI subscription integration remains available with Diffex attribution. -Radius is removed; the older release audit does not authorize its use. -See [migration and validation](release/radius-removal.md). -The catalog authority is now Diffex; see [the catalog contract](model-catalog.md). -The exact residual inventory is [identity-residual-allowlist.md](../identity-residual-allowlist.md). - -| Workspace | Final package | Publication | -| --------------------------------------- | ---------------------------------------------- | ---------------------------- | -| `packages/agent` | `@diffexai/diffex-agent-core` | Private internal workspace | -| `packages/ai` | `@diffexai/diffex-ai` | Private internal workspace | -| `packages/client` | `@diffexai/diffex-client` | Private internal workspace | -| `packages/coding-agent` | `@diffexai/diffex` | Public CLI | -| `packages/coding-agent-server` | `@diffexai/diffex-coding-agent-server` | Private internal workspace | -| `packages/harness-state` | `@diffexai/diffex-harness-state` | Private internal workspace | -| `packages/protocol` | `@diffexai/diffex-protocol` | Private internal workspace | -| `packages/server` | `@diffexai/diffex-server` | Private internal workspace | -| `packages/session-backends/sqlite-node` | `@diffexai/diffex-session-backend-sqlite-node` | Private internal workspace | -| `packages/telemetry` | `@diffexai/diffex-telemetry` | Private internal workspace | -| `packages/tui` | `@diffexai/diffex-tui` | Private internal workspace | -| `packages/evals` | `@diffexai/diffex-evals` | Private | -| `packages/evolution` | `@diffexai/diffex-evolution` | Private | -| `packages/harness-trace` | `@diffexai/diffex-harness-trace` | Private | -| `packages/coding-agent/install-lock` | `@diffexai/diffex-coding-agent-install` | Generated private installer | - -The public package inventory is derived from `scripts/release-packages.mjs`. -Only `@diffexai/diffex` is public; required internal runtime modules are bundled into its tarball under the [distribution policy](release/distribution-policy.json). -The generated installer package remains private and is not an additional publication target. -`@diffexai/diffex-coding-agent` is not a supported package identity. - -Runtime image publication remains disabled pending separate deployment-consumer acceptance. -Public bootstrap, trusted publishing, distribution notices, asset hosting, and installed-client release acceptance remain separate work. -The owner previously verified npm account prerequisites; this implementation does not repeat npm login or ownership checks. diff --git a/docs/model-catalog.md b/docs/model-catalog.md deleted file mode 100644 index f4cc15322..000000000 --- a/docs/model-catalog.md +++ /dev/null @@ -1,133 +0,0 @@ -# Diffex model catalog contract - -The CLI repository owns generation, validation, publication, and persisted client overlays. -`diffex-cloud/services/public-api` owns anonymous serving through `https://api.diffex.ai/api/models/providers/{providerId}`. -No database is involved. -Upstream metadata sources and generator overrides remain unchanged. - -## Storage and publication - -Account `6b24afa849f8254ab5e6e2d1956239cc` hosts private buckets `diffex-artifacts-staging` and `diffex-artifacts`. -The Worker binds the matching bucket as `MODEL_CATALOGS`. -GitHub environments `diffex-model-upload-staging` and `diffex-model-upload-production` each contain bucket-scoped `DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID` and `DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY` secrets and permit only `main`. -Neither bucket needs public access. - -The strict JSON export includes `models.json`, sorted `providers.json`, `providers/{providerId}.json`, and `generation.json` with `generatedAt` and the workflow's resolved `sourceCommit`. -All model fields pass the bundled-model validator before export. -Publication additionally requires matching provider shards, `anthropic`, `openai`, `openrouter`, and at least 500 models. - -The revision is `sha256-` followed by the SHA-256 of the exact `models.json` bytes. -Immutable objects live under `models/v1/revisions/{revision}/`. -That directory contains the models, provider list, shards, and an immutable `publication.json`: - -```json -{ - "schemaVersion": 1, - "minimumDiffexVersion": "0.3.3", - "revision": "sha256-<64 lowercase hexadecimal characters>", - "sourceCommit": "<40 lowercase hexadecimal characters>", - "generatedAt": "2026-10-26T10:00:00.000Z", - "providerIds": ["anthropic", "openai", "openrouter"], - "providerCount": 3, - "modelCount": 510, - "providers": { - "openai": { - "key": "models/v1/revisions/sha256-/providers/openai.json", - "sha256": "", - "modelCount": 170 - } - } -} -``` - -The example abbreviates the provider map; every listed provider must have an entry, and counts must sum exactly. -Identical-content retries preserve the first manifest's source and timestamp. -The initial compatibility floor is `0.3.3`; raise it only with metadata requiring newer client behavior. -Raising a floor requires a new content revision, since a published manifest cannot be edited. - -The mutable `models/v1/index.json` contains `schemaVersion: 1`, `defaultRevision`, `catalogs`, `validatedAt`, and `validationSourceCommit`. -Each tier has `minimumDiffexVersion`, `revision`, `sourceCommit`, `generatedAt`, `providerCount`, and `modelCount`. -Publication replaces only the matching compatibility floor and retains other tiers. -Every successful publication, including unchanged content, advances validation evidence independently of generation age. - -All revision objects use S3 `PutObject` with `If-None-Match: *` and are downloaded and verified before index advancement. -The publisher verifies an existing immutable manifest instead of replacing it. -The index is updated with `If-Match` against the ETag read before uploads, or `If-None-Match: *` for the initial write. -Conflicts fail the run; regenerate from current main and retry. -Interrupted uploads leave the previously selected revision intact. -R2's [consistency guarantees](https://developers.cloudflare.com/r2/reference/consistency/) allow verification directly through private storage without public caches. - -## Serving and cache provenance - -The only new production route is `https://api.diffex.ai/api/models/providers/*`. -Release discovery stays on its existing Worker route; auth, webhooks, `/v1/*`, and production health routes remain on Render. -Provider IDs must match `[a-z0-9][a-z0-9-]{0,99}` with no extra path segments or encoded separators. -Storage keys come exclusively from the selected, validated publication manifest. - -Anonymous `GET` and `HEAD` select the highest compatibility floor supported by the `diffex/` user-agent token. -A prerelease does not satisfy the same stable-version floor. -Unrecognized or absent Diffex versions select `defaultRevision`. -No compatible tier returns `501`; an unknown provider returns `404`; unsupported methods return `405` with `Allow: GET, HEAD`. -Missing, malformed, oversized, corrupt, or inaccessible storage returns `503` and never exposes internal storage errors. - -Successful responses retain the provider's model-ID-keyed JSON object and return `Content-Type: application/json; charset=utf-8`, `Cache-Control: public, no-cache`, and `Vary: User-Agent`. -The quoted ETag is `"{revision}:{providerId}"`. -Cloudflare may add the weak `W/` prefix when compressing a response; clients retain the received validator and probes compare its opaque value. -`Last-Modified` is the immutable generation timestamp rounded to HTTP-date precision. -`If-None-Match` takes precedence over `If-Modified-Since`, supports weak tags and lists for GET/HEAD revalidation, and returns bodyless `304` on a match. -Every HEAD response is bodyless. -The mutable index is read directly from R2 on each request. - -Client cache entries record the exact provider URL as `catalogUrl` and the selecting CLI version as `catalogClientVersion`. -Only entries matching both values may restore models, suppress refreshes for four hours, or send a cached ETag. -Legacy or mismatched entries fall back to bundled models and refresh unconditionally when network access is allowed. -Newer bundled metadata retains precedence. -Successful bodies are fully validated before persistence; failures and cancellation preserve the last valid matching cache. -`diffex update --models` forces refresh, including rollback discovery. - -## Validation and rollout - -Run focused publisher and client tests and `npm run check` in the CLI repository. -Run `npm run check` in `diffex-cloud/services/public-api`. -The opt-in local integration requires the companion checkout, AWS CLI with conditional-put support, and tmux: - -```sh -DIFFEX_CLOUD_DIR=/path/to/diffex-cloud node scripts/catalog-local-e2e.mjs -``` - -It exercises the actual publisher through local R2's S3 API, the Worker runtime, CLI update and persisted cache, the interactive selector, and offline startup using an isolated agent directory. -It blocks unrelated outbound requests and performs no model inference. - -Coordinate repository changes before enabling scheduled publication. -Rehearse against staging with staging-only credentials and verify fixture cleanup. -From the CLI repository, set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_DEFAULT_REGION=auto` in an approved environment, then run: - -```sh -node scripts/catalog-staging-rehearsal.mjs --staging -``` - -The rehearsal requires the catalog Worker to be deployed to staging first. -It creates an isolated synthetic revision, checks interrupted uploads and conditional conflicts, exercises the deployed endpoint and isolated CLI, restores the prior index conditionally, and deletes and verifies only its fixture objects. -It refuses to overwrite a concurrent index change and retains recovery evidence if cleanup fails. -Seed production with a validated real catalog, deploy the narrow Worker route, and verify real `200`/`304` responses and unchanged release/Render routes before shipping the client default. -Do not publish synthetic fixture models to production. - -## Monitoring and rollback - -Weekday publication candidates preserve 10:17, 12:17, and 14:17 Europe/Vienna and the existing successful-main-CI window of 10:00 through 14:59. -Manual publication bypasses the window; PRs only validate. -Generation and publishing code use one resolved SHA; the publisher checks current main before upload and immediately before index advancement. -Uploads serialize per environment without cancelling active uploads. - -`monitor-model-catalog.yml` checks successful generation and actual production upload steps separately, with one hour of scheduling grace and weekend/DST handling. -`diffex-cloud`'s existing monitor probes availability, HEAD, ETag/date validators, release discovery, and Render routing. -Old generation timestamps are healthy when content is unchanged. -Both [GitHub scheduled workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule) can be delayed or dropped, so configure an independent external heartbeat for the monitor and confirm failure notifications after merge. -Repository changes alone do not prove scheduled execution or notification delivery. - -Retain immutable revisions for rollback. -Download and preserve the current index bytes and ETag, verify every object referenced by the desired historical index, then replace the index with `If-Match` against the captured current ETag. -Do not force an index update after a conditional conflict. -Alternatively, roll back to a verified Worker deployment while preserving catalog bindings and narrow routes. -Worker rollback does not roll back R2 objects. -Clients discover a catalog rollback on their next refresh or through `diffex update --models`. diff --git a/docs/release/artifact-acceptance.md b/docs/release/artifact-acceptance.md deleted file mode 100644 index 3d77b2be6..000000000 --- a/docs/release/artifact-acceptance.md +++ /dev/null @@ -1,108 +0,0 @@ -# Local package and native acceptance - -The release gate installs every package returned by `scripts/release-packages.mjs` from its reviewed local tarball. -Each package receives a separate consumer with only its public dependency closure, ordered before the package that requires it. -Consumers live outside the repository in paths containing spaces, with separate homes, configuration, projects, temporary files, and package prefixes. -npm and Bun start with an empty registry cache shared only among consumers in that acceptance run, and Bun registry concurrency is bounded. -pnpm consumers use isolated stores, homes, configuration and global executable directories; no user store or credentials are inherited. -Installed file identities are rechecked in every consumer even when downloaded dependencies are cached. -Native candidate npm consumers explicitly enable dependency lifecycle scripts; pnpm and Bun consumers keep scripts disabled. -The shared `installConsumer` helper still disables scripts by default; acceptance opts npm into the reviewed script-enabled path. -The Diffex registry scope points to an unavailable loopback endpoint, so a missing local override fails instead of fetching an older Diffex package. -External npm dependencies still resolve from the public registry, except for the reviewed Mistral provider bundle. -Only that provider implementation absorbs its required pure-JavaScript SDK code; other implementation paths, declarations, lazy loading, extensions, native bindings and WASM remain unchanged. -Bundling checks exact approved npm versions/licenses, records input hashes and registry integrity, preserves complete license/NOTICE text, and rehashes the final staged runtime inventory. -New unresolved dependency references, location-sensitive inputs, unreviewed dynamic imports, missing legal documents or unexpected bundle output fail packaging. -A mock-fetch regression exercises Mistral streaming, request options, errors and cancellation without live provider credentials. - -Declared runtime exports, wildcard exports, declarations, main files, and executable targets must exist inside the installed package. -Runtime imports and representative operations execute in fresh Node processes. -Installed runtime inventories must equal the inventory extracted from the checked tarball, and every recorded installed file must match its SHA-256 digest. -The operation inventory fails closed if a new public package has no representative operation. - -The current public inventory contains only `@diffexai/diffex`. -Its required internal modules ship inside the tarball, and installation must succeed while separate Diffex registry requests are blocked. -The representative CLI operation initializes model runtime, loads clipboard native code, executes image WASM, and exports a session to HTML. -Hosted server packages remain private and are covered by repository tests rather than public npm artifact acceptance. - -The packed CLI additionally executes help, version, model listing, controlled inference, a terminal prompt/reply, account-menu inspection, and clean exit. -The terminal path exercises packaged themes and the HTML export path exercises its templates, CSS, and bundled renderer resources. -Clipboard coverage loads the native module without reading or replacing the user's clipboard. -Socket guards reject non-loopback process traffic; the terminal fixture supplies the known no-release HTTP response and fails on unexpected socket attempts. -No provider credentials are inherited. -The controlled environment must supply native `fd` and `ripgrep` executables on PATH. -Acceptance executes file discovery and content search, records their versions, and keeps automatic helper downloads blocked. -Native runners install those prerequisites through apt or Homebrew before package acceptance. -This validates the npm installation with controlled system helpers; it does not establish the separate automatic-helper-download lifecycle. - -## Installed lifecycle acceptance - -The acceptance command also runs `scripts/npm-lifecycle.mjs` against the exact reviewed CLI tarball. -It installs globally into an isolated prefix, checks PATH, version, help, and models, then invokes the installed self-updater with real npm subprocesses. -Only discovery and the Diffex-scoped registry are controlled fixtures; dependency downloads use npmjs.org. -This updater/recovery harness intentionally retains scripts-disabled installation and self-update behavior; the candidate consumer and public fresh-install checks separately cover script-enabled npm installation. -An isolated version-metadata predecessor exercises upgrade to the reviewed candidate without publishing a fake release. -Fresh caches require actual target downloads. -Malformed and foreign metadata, redirects, outages, no release, and checksum mismatches are rejected without changing the installed prefix, including force. -An interrupted npm download demonstrates the documented pinned repair and, when npm reports `ENOTEMPTY`, relocation of the affected CLI installation or its exact stale backup before retrying. -Interruption itself can mutate npm's prefix and is not claimed to preserve the executable. -Uninstall verifies executable removal and retained global and project data. -The same harness verifies Bun and pnpm global installation and PATH, rejects a foreign release identity without mutation, upgrades with real package-manager subprocesses, checks the installed runtime hashes, and uninstalls while retaining user and project data. -pnpm upgrades reuse the same store location with its contents cleared, preserving store identity while requiring fresh package downloads. -pnpm's shell executable shim and package links are checked against the isolated consumer rather than treated as npm-style direct executable symlinks. -An isolated Bun global scope registry ensures that the upgrade receives the reviewed candidate bytes. -`npm-lifecycle.json` records commands, identities, results, limitations, and cleanup. - -## Supported distribution scope - -The approved channel remains the npm registry, installed with npm, pnpm or Bun and executed on Node 22.19.0 or newer. -Bun package installation is distinct from a Bun standalone executable. -Standalone publication remains deferred for every platform under the approved redistribution policy, and the manifest advertises no binary assets or binary platforms. -Windows npm targets are now explicitly deferred until equivalent terminal acceptance is available. -The advertised npm targets are Darwin arm64/x64 and Linux arm64/x64. -The reusable native workflow uses clean native runners for those four targets, downloads the same retained tarballs, verifies their source and checksums, and runs the complete acceptance command before npm publication can proceed. -Runner labels follow the [GitHub-hosted runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). - -## Commands and evidence - -Build, pack, and accept the current local candidate: - -```sh -npm run release:local -- --out '/tmp/diffex local release' -``` - -Accept existing reviewed artifacts without rebuilding or repacking: - -```sh -node scripts/artifact-acceptance.mjs /path/to/artifacts /path/to/private-evidence -``` - -The command accepts both the local manifest/tarballs layout and the frozen release artifact layout. -`acceptance.json` records the source commit, OS, architecture, Node and package-manager versions, each package's imports and operation result, lifecycle script modes for package and process consumers, exact tarball checksums, process outcomes, and cleanup. -A failed run records failure and the completed subset; it cannot produce a passing overall status. -Terminal transcripts and smoke events are retained beside it in private evidence storage. -Every test-owned consumer, session, cache, configuration directory, and server process is removed, including on failure. - -Verify final tarball bytes from their directory using the retained checksum list: - -```sh -shasum -a 256 -c /path/to/private-evidence/SHA256SUMS -``` - -On Linux, `sha256sum -c` accepts the same checksum file. -Regression coverage alters archive bytes and confirms verification fails before installation. -The release workflow retains the source snapshot and manifests privately, while npm publication supplies registry integrity through the existing publisher. -Trusted publishing authenticates the private source workflow, but npm does not generate provenance for private repositories. -Public acceptance must compare the actual downloaded npm bytes with these retained identities after separately authorized publication. -Its initial predecessor install and fresh release reinstall require npm's effective `ignore-scripts` configuration to be `false` before each install and omit the `--ignore-scripts` flag. -These public lifecycle installs use exact versions so the predecessor and release identities remain deterministic; the separate Daytona `npm-global-installation` scenario checks the unversioned `npm i -g @diffexai/diffex` command. -Public pnpm and Bun installs continue to disable scripts, and the installed self-updater intentionally remains scripts-disabled for every manager. -Public evidence records each initial/fresh installation's lifecycle mode and npm configuration result separately from the updater mode. -Changed Daytona scenarios remain a pending remote execution and evidence-review gate; local tests do not establish that result. - -The baseline CLI-only acceptance passed on Darwin arm64 but emitted no per-package operation or hosted-server result. -The previous expanded acceptance covered eleven packages and the hosted server lifecycle before the owner reduced the public inventory on 2026-09-13. -Current acceptance validates the complete CLI-only tarball through npm, pnpm and Bun. -The owner specified that no live provider test environment is available, so real login and inference remain pending. -Native runner results and exact committed-candidate evidence are recorded in the implementation pull request and accompanying acceptance record. -Public downloads and public-to-public upgrades remain unexecuted until publication; local fixture inference does not establish those results. diff --git a/docs/release/candidate-artifacts.json b/docs/release/candidate-artifacts.json deleted file mode 100644 index da7f4af08..000000000 --- a/docs/release/candidate-artifacts.json +++ /dev/null @@ -1,398 +0,0 @@ -{ - "sourceCommit": "33704b32c5d17b849c013834fd1c56d65de843fd", - "date": "2026-09-11", - "version": "0.2.0", - "environment": { - "os": "macOS 26.1", - "kernel": "Darwin 25.1.0", - "architecture": "arm64", - "node": "22.19.0", - "npm": "10.9.3", - "bun": "1.3.11", - "python": "3.13" - }, - "artifacts": [ - { - "name": "@diffexai/diffex-agent-core", - "version": "0.2.0", - "filename": "diffexai-diffex-agent-core-0.2.0.tgz", - "sha256": "10a10787c5958f3599790741de1e3a0d5b45f2bf9caae4f8bd03390cf7e30508" - }, - { - "name": "@diffexai/diffex-ai", - "version": "0.2.0", - "filename": "diffexai-diffex-ai-0.2.0.tgz", - "sha256": "f97e33175b3dc6b85ae463c434c1ae0f65b0b8682e820a046b8a1315cd5ba909" - }, - { - "name": "@diffexai/diffex-client", - "version": "0.2.0", - "filename": "diffexai-diffex-client-0.2.0.tgz", - "sha256": "2f87cace71c708ed98a51f971c9c2e854ef820224eff4c6ca199003e96052e0b" - }, - { - "name": "@diffexai/diffex", - "version": "0.2.0", - "filename": "diffexai-diffex-0.2.0.tgz", - "sha256": "0bbb793d77b70adeaa3c08c6e8f366b5f8fbcdbc3e7d843e82cffdc37994ab74" - }, - { - "name": "@diffexai/diffex-coding-agent-server", - "version": "0.2.0", - "filename": "diffexai-diffex-coding-agent-server-0.2.0.tgz", - "sha256": "fc9bf1f501f24d991a552d146d23b41391cd94b28d00b6a0289acb0d2cb671ba" - }, - { - "name": "@diffexai/diffex-harness-state", - "version": "0.2.0", - "filename": "diffexai-diffex-harness-state-0.2.0.tgz", - "sha256": "9235d38d4efb96370d7da95ccca3666b0152e347f18defb9646e7dcb8b8f37ae" - }, - { - "name": "@diffexai/diffex-protocol", - "version": "0.2.0", - "filename": "diffexai-diffex-protocol-0.2.0.tgz", - "sha256": "1bd0590a817451289475a8b2c9b15d3e36ca11db3790f9a98be7123de3ad6816" - }, - { - "name": "@diffexai/diffex-server", - "version": "0.2.0", - "filename": "diffexai-diffex-server-0.2.0.tgz", - "sha256": "48d7a28a526c35fa032095cf79d302f06f625027fe6ae185925dbd921fd7c34d" - }, - { - "name": "@diffexai/diffex-session-backend-sqlite-node", - "version": "0.2.0", - "filename": "diffexai-diffex-session-backend-sqlite-node-0.2.0.tgz", - "sha256": "d23826ab601981f1782bb156b907f4398f0a9e435e98c052dd97194a18da6b2b" - }, - { - "name": "@diffexai/diffex-telemetry", - "version": "0.2.0", - "filename": "diffexai-diffex-telemetry-0.2.0.tgz", - "sha256": "0f846a375378b6c795c44f9f57f9d89672423ef9a24e04160be69d10ac3a6afb" - }, - { - "name": "@diffexai/diffex-tui", - "version": "0.2.0", - "filename": "diffexai-diffex-tui-0.2.0.tgz", - "sha256": "27bbaa49e67f13dbca060970065a92836885682f8b5c5c3a95e2d23782004c02" - }, - { - "filename": "binaries/darwin-arm64/diffex", - "sha256": "8e4b67e1453c3af626ac082c5f9379c9c11c398c8c7ad3004af70d5e1bf9c416" - }, - { - "filename": "binaries/diffex-darwin-arm64.tar.gz", - "sha256": "c3242d874350bb7328fdb5bfd3c6cb7823595b5116093017108cd0f5402934bf" - } - ], - "providers": [ - { - "id": "amazon-bedrock", - "name": "Amazon Bedrock" - }, - { - "id": "ant-ling", - "name": "Ant Ling" - }, - { - "id": "anthropic", - "name": "Anthropic" - }, - { - "id": "azure-openai-responses", - "name": "Azure OpenAI" - }, - { - "id": "baseten", - "name": "Baseten" - }, - { - "id": "cerebras", - "name": "Cerebras" - }, - { - "id": "cloudflare-ai-gateway", - "name": "Cloudflare AI Gateway" - }, - { - "id": "cloudflare-workers-ai", - "name": "Cloudflare Workers AI" - }, - { - "id": "deepseek", - "name": "DeepSeek" - }, - { - "id": "fireworks", - "name": "Fireworks" - }, - { - "id": "github-copilot", - "name": "GitHub Copilot" - }, - { - "id": "google", - "name": "Google" - }, - { - "id": "google-vertex", - "name": "Google Vertex AI" - }, - { - "id": "groq", - "name": "Groq" - }, - { - "id": "huggingface", - "name": "Hugging Face" - }, - { - "id": "kimi-coding", - "name": "Kimi For Coding" - }, - { - "id": "minimax", - "name": "MiniMax" - }, - { - "id": "minimax-cn", - "name": "MiniMax CN" - }, - { - "id": "mistral", - "name": "Mistral" - }, - { - "id": "moonshotai", - "name": "Moonshot AI" - }, - { - "id": "moonshotai-cn", - "name": "Moonshot AI CN" - }, - { - "id": "nvidia", - "name": "NVIDIA" - }, - { - "id": "openai", - "name": "OpenAI" - }, - { - "id": "openai-codex", - "name": "OpenAI Codex" - }, - { - "id": "opencode", - "name": "OpenCode Zen" - }, - { - "id": "opencode-go", - "name": "OpenCode Go" - }, - { - "id": "openrouter", - "name": "OpenRouter" - }, - { - "id": "qwen-token-plan", - "name": "Qwen Token Plan" - }, - { - "id": "qwen-token-plan-cn", - "name": "Qwen Token Plan CN" - }, - { - "id": "qwen-token-plan-individual", - "name": "Qwen Token Plan Individual" - }, - { - "id": "radius", - "name": "Radius" - }, - { - "id": "together", - "name": "Together" - }, - { - "id": "vercel-ai-gateway", - "name": "Vercel AI Gateway" - }, - { - "id": "xai", - "name": "xAI" - }, - { - "id": "xiaomi", - "name": "Xiaomi" - }, - { - "id": "xiaomi-token-plan-ams", - "name": "Xiaomi Token Plan AMS" - }, - { - "id": "xiaomi-token-plan-cn", - "name": "Xiaomi Token Plan CN" - }, - { - "id": "xiaomi-token-plan-sgp", - "name": "Xiaomi Token Plan SGP" - }, - { - "id": "zai", - "name": "Z.AI" - }, - { - "id": "zai-coding-cn", - "name": "Z.AI Coding CN" - } - ], - "identityScan": { - "sourceFiles": 912, - "sourceOccurrences": 17, - "artifactFilesWithMatches": 31, - "rawArtifactOccurrences": 54, - "approvedOrLegalRawOccurrences": 49, - "incidentalThirdPartyOccurrences": 5, - "decodedSourceMapOccurrences": 28, - "review": "candidate-identity-review.json" - }, - "commands": [ - { - "command": "npm run build:offline", - "exitCode": 0 - }, - { - "command": "npm run check", - "exitCode": 0 - }, - { - "command": "./test.sh", - "exitCode": 0 - }, - { - "command": "node --test scripts/check-runtime-identities.test.mjs scripts/issue-analysis.test.mjs", - "exitCode": 0, - "tests": 10 - }, - { - "command": "node scripts/prepare-candidate-artifacts.mjs /tmp/diffex-candidate-handoff", - "exitCode": 0 - }, - { - "command": "./scripts/build-binaries.sh --skip-install --skip-deps --skip-build --platform darwin-arm64 --out /tmp/diffex-candidate-handoff/binaries", - "exitCode": 0 - }, - { - "command": "python3 scripts/verify-candidate-artifacts.py /tmp/diffex-candidate-handoff", - "exitCode": 0 - }, - { - "command": "node scripts/scan-candidate-artifacts.mjs /tmp/diffex-candidate-handoff", - "exitCode": 0 - } - ], - "smoke": [ - { - "runtime": "node", - "command": ["--version"], - "exitCode": 0 - }, - { - "runtime": "node", - "command": ["--help"], - "exitCode": 0 - }, - { - "runtime": "node", - "command": ["auth", "--help"], - "exitCode": 0 - }, - { - "runtime": "node", - "command": ["--list-models"], - "exitCode": 0, - "expected": "fixture" - }, - { - "runtime": "node", - "command": ["-p", "Reply with the fixture confirmation."], - "exitCode": 0, - "expected": "handoff-fixture-ok" - }, - { - "runtime": "node", - "command": "interactive prompt, login selector, exit", - "exitCode": 0, - "events": [ - { - "type": "inference", - "provider": "handoff" - }, - { - "type": "request", - "url": "https://api.diffex.ai/api/latest-version" - }, - { - "type": "inference", - "provider": "handoff" - } - ], - "accounts": ["OpenAI Codex", "Radius", "xAI"], - "cleanup": "process exited and isolated directories removed" - }, - { - "runtime": "bun", - "command": ["--version"], - "exitCode": 0 - }, - { - "runtime": "bun", - "command": ["--help"], - "exitCode": 0 - }, - { - "runtime": "bun", - "command": ["auth", "--help"], - "exitCode": 0 - }, - { - "runtime": "bun", - "command": ["--list-models"], - "exitCode": 0, - "expected": "fixture" - }, - { - "runtime": "bun", - "command": ["-p", "Reply with the fixture confirmation."], - "exitCode": 0, - "expected": "handoff-fixture-ok" - }, - { - "runtime": "bun", - "command": "interactive prompt, login selector, exit", - "exitCode": 0, - "events": [ - { - "type": "inference", - "provider": "handoff" - }, - { - "type": "request", - "url": "https://api.diffex.ai/api/latest-version" - }, - { - "type": "inference", - "provider": "handoff" - } - ], - "accounts": ["OpenAI Codex", "Radius", "xAI"], - "cleanup": "process exited and isolated directories removed" - } - ], - "liveLoginAndInference": "Skipped at the explicit request of the owner.", - "cleanup": "All isolated smoke homes, projects, caches, credentials and processes removed. No remote fixtures, sessions or sandboxes created. Local artifacts retained in /tmp/diffex-candidate-handoff.", - "publication": "No npm packages, GitHub releases, source archives, hosted images or deployment changes published." -} diff --git a/docs/release/candidate-handoff.md b/docs/release/candidate-handoff.md deleted file mode 100644 index 65c5666a7..000000000 --- a/docs/release/candidate-handoff.md +++ /dev/null @@ -1,140 +0,0 @@ -# Release candidate identity and backend acceptance - -This handoff reconciles the identity migration and backend exposure plans with the candidate on 2026-09-11. -The implementation is based on CLI `b45656e880071eaa5c4792951c44fee19e3e493a`, PR #207. -The accepted implementation is [`33704b32c5d17b849c013834fd1c56d65de843fd`](https://github.com/diffexai/diffex/commit/33704b32c5d17b849c013834fd1c56d65de843fd). -The follow-up evidence commit changes only documents; [the artifact manifest](candidate-artifacts.json) pins the tested implementation and bytes. -The companion cloud change is stacked on PR #147. - -Current policy supersedes this historical candidate's service, OAuth registration, and application-attribution approvals. -Radius is removed and must not be used in development, testing, evaluation, or production. -The pinned JSON manifests and identity reports below preserve historical bytes, including former URLs and headers, and must not be reused as release candidates. -Their approval labels are historical evidence, not current runtime exceptions. -Current catalog ownership is defined by [the catalog contract](../model-catalog.md), and current ChatGPT registration and inference by [provider setup](../../packages/coding-agent/docs/providers.md#chatgpt). -See [Radius removal](radius-removal.md) for current migration and validation. - -## Reconciled decisions - -The canonical private source, package map, executable, hosted image identity, and Luntraa, Inc. copyright are recorded in [the identity handoff](../identity-handoff.md). -The historical release audit approved external catalogs and provider attribution. -The owner additionally instructed that implementation to retain OpenAI Codex and xAI OAuth. -Current provider defaults identify Diffex, and ChatGPT now uses a Diffex registration and public APIs instead of the inherited client registration. -Radius gateway/OAuth approval has been revoked; no replacement Radius service is planned. -No Diffex ownership of those external services or registrations is asserted. - -The obsolete security/staff exceptions are removed. -Issue analysis authorizes against repository write/admin permission and fails closed on insufficient permission or GitHub errors. -The inherited organization token, staff endpoint, security mailbox, and ownership wording are absent. -Gondolin and its native runners are removed from the example payload, workspace graph, and generated lockfile. -Legacy extension-package aliases, the old theme symbol, and upstream session-sharing guidance are removed. -Extensions must import canonical `@diffexai` packages; wrappers embedding exported HTML must use the new Diffex metadata keys. -Previously saved sidebar widths under the old local-storage key are not migrated. -Export HTML metadata uses Diffex names. -Historical changelog links now retain their original upstream destination instead of being attributed to Diffex issues. -Released changelog files and upstream legal notices are unchanged. - -Install reporting remains removed. -The updater still accepts only canonical stable versions for `@diffexai/diffex`, pins installation to the verified version, and treats the no-release response as no update. -The [exact residual inventory](../../identity-residual-allowlist.md) replaces the obsolete exceptions and is enforced by `npm run check:identities`. - -## Current endpoint ownership and allowed egress - -This table reflects current source behavior, not the endpoints captured in the historical JSON evidence. - -| Operation | Endpoint or authority | Policy | -| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| Release discovery | `https://api.diffex.ai/api/latest-version` | Diffex Cloud public Worker; anonymous; fixed canonical package identity; no redirects | -| Diffex account login and product APIs | `https://api.diffex.ai`, existing Render/WorkOS routes | Separate account credentials | -| Built-in catalog refresh | `https://api.diffex.ai/api/models/providers/{providerId}` | Diffex-owned catalogs; cache provenance and compatibility follow the catalog contract | -| ChatGPT (`openai-codex`) | `https://auth.openai.com`, `https://api.openai.com/v1` | Diffex account registration; public account model catalog and Responses API; inherited registrations require sign-in again | -| xAI subscription | `https://auth.x.ai/oauth2/device/code`, `https://auth.x.ai/oauth2/token`; client `b1a00492-073a-47ea-816f-4c329264a828` | Existing subscription OAuth with Diffex attribution | -| Other model providers | Selected built-in provider endpoints or explicitly configured custom endpoints | Provider-specific credentials and model traffic | -| Optional attribution | OpenRouter, NVIDIA NIM, Cloudflare | Diffex application headers; default off; separate from removed install reporting | -| OpenCode attribution | Selected OpenCode endpoint | Diffex client identity and session header, independent of optional attribution | - -Radius discovery, authentication, and inference are no longer supported or allowed egress. -Custom gateways remain explicit user configuration. -No runtime egress permission extends to install-report routes, upstream update discovery, security reporting to upstream contacts, or external organization authorization. -GitHub access remains available for explicitly requested extension/package operations and source-host operations. - -## Catalog and provider policy - -The frozen built-in catalog was generated at `2026-08-25T03:02:44.635Z` with structure hash `888155e376e18c5eb02a88c79c374edaa9a517730bfe17f82f76ca51c96cca12`. -The local build validates and uses that checked-in snapshot without refreshing model metadata. -`packages/ai/src/providers/data/.manifest.json` records every provider shard digest. -The static provider IDs in that historical manifest describe the candidate's catalog. -Dynamic `radius` is no longer supported by the source adapters. -The installed artifact manifest lists all 40 provider IDs and display names; listing a provider does not claim live authentication or inference acceptance. - -Configured built-in providers use the Diffex-owned overlay and its four-hour refresh interval. -Only caches matching the exact catalog URL and CLI version can restore models or validators; former-host caches trigger an unconditional refresh when online. -ChatGPT uses its signed-in account's public model catalog and an account-specific cache instead. -Newer remote models override same-ID built-ins; cached bodies support ETag `304` responses. -Older or undated overlays do not override the frozen built-in generation timestamp. -Unavailable `404`/`501` overlays and transient errors preserve the documented built-in/cache fallback. -Offline mode suppresses startup management traffic; it is not a network sandbox for explicit user-requested inference or arbitrary extensions. - -## Cloud revision and routing evidence - -The companion [cloud handoff](https://github.com/diffexai/diffex-cloud/blob/chore/release-candidate-handoff/docs/release-candidate-handoff.md) retains deployment provenance and an anonymous production probe. -Production activation source is `d6232aee52f8056d9a72c93e56313b298521780a`, Worker version `51b928ad-08c5-4523-a9f5-96bc85c49d29`, deployment `4b1a02e9-03d8-41ff-a2c5-cb054db3e162`, workflow run `34551833957`. -The last recorded restored staging version is `1055f9cc-c142-452c-9fd2-500849b43c6d`, deployment `2f3ea9c7-cd09-4e89-9ef9-5104d83c5505`, from source `a88c1ba3fc17eea90d2dfe7adee066ea0353d681`. -These are existing deployment records, not a new authenticated inventory. -The fresh public probe returned exact `404 no_published_release`, `200` Render readiness, and `401` anonymous auth rejection. -No deployment or production metadata mutation was needed. - -## Validation and explicit exclusions - -The full `./test.sh` suite passed without provider credentials. -That historical run included Radius, OpenAI Codex, xAI, remote catalog, provider-attribution, install-report removal, and updater regressions. -Current Radius tests assert rejection without service access. -The new issue-workflow tests first reproduced rejection of Diffex maintainers because an inherited token was absent, then passed after repository-scoped authorization replaced it. -The CLI quality gate and exact identity scan passed. -The cloud/CLI contract checker passed with no public contract changes. - -All eleven local workspace tarballs installed together outside the repository using local file overrides and lifecycle scripts disabled. -Node and macOS arm64 Bun artifacts passed help, version, model listing, controlled print inference, interactive prompt/reply, account-menu display, and clean exit. -The controlled startup request log contains only `https://api.diffex.ai/api/latest-version`, returning the no-release response. -No network connections or real provider calls are permitted by the artifact fixture once loaded. -The fixture is a test extension and is not shipped or activated in production. - -Prepare fresh artifacts after the offline build, then repeat the terminal acceptance and scan: - -```sh -npm run build:offline -node scripts/prepare-candidate-artifacts.mjs /tmp/diffex-candidate-handoff -./scripts/build-binaries.sh --skip-install --skip-deps --skip-build --platform darwin-arm64 --out /tmp/diffex-candidate-handoff/binaries -python3 scripts/verify-candidate-artifacts.py /tmp/diffex-candidate-handoff -node scripts/scan-candidate-artifacts.mjs /tmp/diffex-candidate-handoff -``` - -Use a fresh output directory for another run; the preparation script rejects an existing Node consumer to prevent reuse of cached same-version tarballs. -This was run on macOS 26.1 arm64 with Node 22.19.0, npm 10.9.3, Bun 1.3.11, and Python 3.13. -The complete offline workspace build passed, and the final CLI package was rebuilt after comment-only cleanup before packing. -All eleven local tarballs use version `0.2.0` and are fixture candidates, not public releases. -Their SHA-256 values, the standalone executable/archive hashes, provider inventory, exact commands, exit codes, and smoke results are retained in [candidate-artifacts.json](candidate-artifacts.json). - -The [artifact identity review](candidate-identity-review.json) binds every match to an unpacked file hash, byte offset and context, or decoded source-map line. -It records 49 raw approved-integration/legal matches and 28 approved matches from decoded source maps under the policy at that revision. -Those historical approvals do not authorize the former catalogs, registrations, attribution headers, or Radius service in current source. -Five additional lexical hits are identified separately: an XML processing-instruction error, three syntax-highlighting mathematical constants, and Jiti's bundled detection of an external Pi agent from PATH. -The detector neither names Diffex as Pi nor uses a Pi package, service, credential, or organization; it is third-party interoperability vocabulary rather than an inherited Diffex identity. -These contexts do not expand the runtime identity allowlist or authorize any new egress. -Immutable changelog files are hashed and counted separately under the instruction to preserve released history. -The scan covers the installed contents of all eleven Diffex packages and the complete unpacked native payload, including source maps, examples, and bundled code. -It is an explicit candidate review, not a distribution-notice or all-dependency supply-chain approval. - -Repeat only the terminal acceptance against prepared artifacts with: - -```sh -python3 scripts/verify-candidate-artifacts.py /tmp/diffex-candidate-handoff -``` - -Each run creates isolated home, configuration, project, cache, and event directories, then removes them and checks process exit. -The evidence directory retains only local artifacts and sanitized results. -No hosted session, sandbox, login, or remote fixture was created. - -The owner explicitly requested skipping live login and inference acceptance. -Provider-boundary tests and the local controlled provider do not establish live provider acceptance. -Public package installation/update, public binary distribution, other native targets, signing, notice completeness, hosted image promotion, and scheduled monitoring remain separate release work. -No bootstrap package, general release, source archive, or hosted image was published. diff --git a/docs/release/candidate-identity-review.json b/docs/release/candidate-identity-review.json deleted file mode 100644 index fb2439453..000000000 --- a/docs/release/candidate-identity-review.json +++ /dev/null @@ -1,976 +0,0 @@ -{ - "sourceCommit": "33704b32c5d17b849c013834fd1c56d65de843fd", - "offsetEncoding": "zero-based byte offsets in the unpacked file; source-map decoded entries use source line numbers", - "files": [ - { - "path": "node/node_modules/@diffexai/diffex/CHANGELOG.md", - "sha256": "00b1501c588c75cd7c12e9769e0f8e1c14b87663b608a8da3a1e87fc3acb185c", - "historicalOccurrences": 1824, - "review": "Immutable changelog history preserved under repository instructions; no current runtime identity or egress permission." - }, - { - "path": "node/node_modules/@diffexai/diffex/dist/core/provider-attribution.d.ts.map", - "sha256": "5336b5b7016a6151e21da7ba9a267e12a18fe9aae321fe63100418b1c5691f67", - "occurrences": [ - { - "identity": "pi.dev", - "offset": 2172, - "context": "\\t}\\n\\n\\tif (isOpenRouterModel(model)) {\\n\\t\\treturn {\\n\\t\\t\\t\\\"HTTP-Referer\\\": \\\"https://pi.dev\\\",\\n\\t\\t\\t\\\"X-OpenRouter-Title\\\": \\\"pi\\\",\\n\\t\\t\\t\\\"X-OpenRouter-Categories\\\": \\\"cli-agent\\\",\\n\\t\\t}", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"HTTP-Referer\": \"https://pi.dev\"," - ] - }, - { - "identity": "pi-coding-agent", - "offset": 2468, - "context": "\\n\\t\\t};\\n\\t}\\n\\n\\tif (isCloudflareModel(model)) {\\n\\t\\treturn {\\n\\t\\t\\t\\\"User-Agent\\\": \\\"pi-coding-agent\\\",\\n\\t\\t};\\n\\t}\\n\\n\\treturn undefined;\\n}\\n\\nfunction getSessionHeaders(model: Model, sessionI", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"User-Agent\": \"pi-coding-agent\"," - ] - } - ], - "decodedSources": [ - { - "path": "../../src/core/provider-attribution.ts", - "line": 50, - "identity": "pi.dev", - "context": "\t\t\t\"HTTP-Referer\": \"https://pi.dev\",", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts" - }, - { - "path": "../../src/core/provider-attribution.ts", - "line": 51, - "identity": "pi", - "context": "\t\t\t\"X-OpenRouter-Title\": \"pi\",", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts" - }, - { - "path": "../../src/core/provider-attribution.ts", - "line": 58, - "identity": "Pi", - "context": "\t\t\t\"X-BILLING-INVOKE-ORIGIN\": \"Pi\",", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts" - }, - { - "path": "../../src/core/provider-attribution.ts", - "line": 64, - "identity": "pi-coding-agent", - "context": "\t\t\t\"User-Agent\": \"pi-coding-agent\",", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts" - }, - { - "path": "../../src/core/provider-attribution.ts", - "line": 80, - "identity": "pi", - "context": "\treturn { \"x-opencode-session\": sessionId, \"x-opencode-client\": \"pi\" };", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex/dist/core/provider-attribution.js", - "sha256": "9065c87bbb1c18469e82a90dd9bee1993976f5297d494e8bbee8d88e4d16b843", - "occurrences": [ - { - "identity": "pi.dev", - "offset": 1381, - "context": " if (isOpenRouterModel(model)) {\n return {\n \"HTTP-Referer\": \"https://pi.dev\",\n \"X-OpenRouter-Title\": \"pi\",\n \"X-OpenRouter-Categories\": \"cli-agent\",\n ", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"HTTP-Referer\": \"https://pi.dev\"," - ] - }, - { - "identity": "pi", - "offset": 1425, - "context": "return {\n \"HTTP-Referer\": \"https://pi.dev\",\n \"X-OpenRouter-Title\": \"pi\",\n \"X-OpenRouter-Categories\": \"cli-agent\",\n };\n }\n if (isNvidiaNimModel(mod", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"X-OpenRouter-Title\": \"pi\",", - "\treturn { \"x-opencode-session\": sessionId, \"x-opencode-client\": \"pi\" };" - ] - }, - { - "identity": "Pi", - "offset": 1591, - "context": " if (isNvidiaNimModel(model)) {\n return {\n \"X-BILLING-INVOKE-ORIGIN\": \"Pi\",\n };\n }\n if (isCloudflareModel(model)) {\n return {\n \"User-Agent\": \"", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"X-BILLING-INVOKE-ORIGIN\": \"Pi\"," - ] - }, - { - "identity": "pi-coding-agent", - "offset": 1693, - "context": " };\n }\n if (isCloudflareModel(model)) {\n return {\n \"User-Agent\": \"pi-coding-agent\",\n };\n }\n return undefined;\n}\nfunction getSessionHeaders(model, sessionId) {\n if (!", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"User-Agent\": \"pi-coding-agent\"," - ] - }, - { - "identity": "pi", - "offset": 2084, - "context": "turn undefined;\n }\n return { \"x-opencode-session\": sessionId, \"x-opencode-client\": \"pi\" };\n}\nexport function mergeProviderAttributionHeaders(model, settingsManager, sessionId, ...headerS", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"X-OpenRouter-Title\": \"pi\",", - "\treturn { \"x-opencode-session\": sessionId, \"x-opencode-client\": \"pi\" };" - ] - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex/dist/core/provider-attribution.js.map", - "sha256": "c4de68eec369b331cac3a6045b98c8cbaaa8b5d505fe6e7fe85982bd64be8c14", - "occurrences": [ - { - "identity": "pi.dev", - "offset": 4172, - "context": "\\t}\\n\\n\\tif (isOpenRouterModel(model)) {\\n\\t\\treturn {\\n\\t\\t\\t\\\"HTTP-Referer\\\": \\\"https://pi.dev\\\",\\n\\t\\t\\t\\\"X-OpenRouter-Title\\\": \\\"pi\\\",\\n\\t\\t\\t\\\"X-OpenRouter-Categories\\\": \\\"cli-agent\\\",\\n\\t\\t}", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"HTTP-Referer\": \"https://pi.dev\"," - ] - }, - { - "identity": "pi-coding-agent", - "offset": 4468, - "context": "\\n\\t\\t};\\n\\t}\\n\\n\\tif (isCloudflareModel(model)) {\\n\\t\\treturn {\\n\\t\\t\\t\\\"User-Agent\\\": \\\"pi-coding-agent\\\",\\n\\t\\t};\\n\\t}\\n\\n\\treturn undefined;\\n}\\n\\nfunction getSessionHeaders(model: Model, sessionI", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"User-Agent\": \"pi-coding-agent\"," - ] - } - ], - "decodedSources": [ - { - "path": "../../src/core/provider-attribution.ts", - "line": 50, - "identity": "pi.dev", - "context": "\t\t\t\"HTTP-Referer\": \"https://pi.dev\",", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts" - }, - { - "path": "../../src/core/provider-attribution.ts", - "line": 51, - "identity": "pi", - "context": "\t\t\t\"X-OpenRouter-Title\": \"pi\",", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts" - }, - { - "path": "../../src/core/provider-attribution.ts", - "line": 58, - "identity": "Pi", - "context": "\t\t\t\"X-BILLING-INVOKE-ORIGIN\": \"Pi\",", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts" - }, - { - "path": "../../src/core/provider-attribution.ts", - "line": 64, - "identity": "pi-coding-agent", - "context": "\t\t\t\"User-Agent\": \"pi-coding-agent\",", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts" - }, - { - "path": "../../src/core/provider-attribution.ts", - "line": 80, - "identity": "pi", - "context": "\treturn { \"x-opencode-session\": sessionId, \"x-opencode-client\": \"pi\" };", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex/dist/core/remote-catalog-provider.d.ts", - "sha256": "39ad152c748050a7641c5735d9555b1625225f8b0912a0a26bd61165d6bd9a0c", - "occurrences": [ - { - "identity": "pi.dev", - "offset": 138, - "context": "ai\";\nexport declare const REMOTE_CATALOG_REFRESH_INTERVAL_MS: number;\n/** Add a persisted pi.dev catalog overlay to a static built-in provider. */\nexport declare function withRemoteCatalog(provide", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts", - "reviewedSourceContexts": [ - "const DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";", - "/** Add a persisted pi.dev catalog overlay to a static built-in provider. */" - ] - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex/dist/core/remote-catalog-provider.d.ts.map", - "sha256": "defd3abaf3695ee9fb82395846d98866d8e85eebefc3280c158efd83e3413f4c", - "occurrences": [ - { - "identity": "pi.dev", - "offset": 680, - "context": "try } from \\\"../utils/management-http.ts\\\";\\n\\nconst DEFAULT_CATALOG_BASE_URL = \\\"https://pi.dev\\\";\\nexport const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;\\n\\nfunction mergeModels(b", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts", - "reviewedSourceContexts": [ - "const DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";", - "/** Add a persisted pi.dev catalog overlay to a static built-in provider. */" - ] - }, - { - "identity": "pi.dev", - "offset": 2107, - "context": "calGeneratedAt)) {\\n\\t\\treturn [];\\n\\t}\\n\\treturn entry.models;\\n}\\n\\n/** Add a persisted pi.dev catalog overlay to a static built-in provider. */\\nexport function withRemoteCatalog(\\n\\tprovider: ", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts", - "reviewedSourceContexts": [ - "const DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";", - "/** Add a persisted pi.dev catalog overlay to a static built-in provider. */" - ] - } - ], - "decodedSources": [ - { - "path": "../../src/core/remote-catalog-provider.ts", - "line": 6, - "identity": "pi.dev", - "context": "const DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts" - }, - { - "path": "../../src/core/remote-catalog-provider.ts", - "line": 44, - "identity": "pi.dev", - "context": "/** Add a persisted pi.dev catalog overlay to a static built-in provider. */", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex/dist/core/remote-catalog-provider.js", - "sha256": "df1ec660af92809908eae517752d818ecf17237b04ca70f0f81a9f0cae06229a", - "occurrences": [ - { - "identity": "pi.dev", - "offset": 212, - "context": "WithRetry } from \"../utils/management-http.js\";\nconst DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";\nexport const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;\nfunction mergeModels(baseli", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts", - "reviewedSourceContexts": [ - "const DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";", - "/** Add a persisted pi.dev catalog overlay to a static built-in provider. */" - ] - }, - { - "identity": "pi.dev", - "offset": 1514, - "context": "alGeneratedAt)) {\n return [];\n }\n return entry.models;\n}\n/** Add a persisted pi.dev catalog overlay to a static built-in provider. */\nexport function withRemoteCatalog(provider, catal", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts", - "reviewedSourceContexts": [ - "const DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";", - "/** Add a persisted pi.dev catalog overlay to a static built-in provider. */" - ] - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex/dist/core/remote-catalog-provider.js.map", - "sha256": "9c27a2ba2962bad047046d670e3de217a48ac413677b4d21820173b28bf7981b", - "occurrences": [ - { - "identity": "pi.dev", - "offset": 4959, - "context": "try } from \\\"../utils/management-http.ts\\\";\\n\\nconst DEFAULT_CATALOG_BASE_URL = \\\"https://pi.dev\\\";\\nexport const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;\\n\\nfunction mergeModels(b", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts", - "reviewedSourceContexts": [ - "const DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";", - "/** Add a persisted pi.dev catalog overlay to a static built-in provider. */" - ] - }, - { - "identity": "pi.dev", - "offset": 6386, - "context": "calGeneratedAt)) {\\n\\t\\treturn [];\\n\\t}\\n\\treturn entry.models;\\n}\\n\\n/** Add a persisted pi.dev catalog overlay to a static built-in provider. */\\nexport function withRemoteCatalog(\\n\\tprovider: ", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts", - "reviewedSourceContexts": [ - "const DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";", - "/** Add a persisted pi.dev catalog overlay to a static built-in provider. */" - ] - } - ], - "decodedSources": [ - { - "path": "../../src/core/remote-catalog-provider.ts", - "line": 6, - "identity": "pi.dev", - "context": "const DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts" - }, - { - "path": "../../src/core/remote-catalog-provider.ts", - "line": 44, - "identity": "pi.dev", - "context": "/** Add a persisted pi.dev catalog overlay to a static built-in provider. */", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex/examples/extensions/doom-overlay/README.md", - "sha256": "76502ed664f76f32b10fda35760b5d2cde407490d3567c2a4c6f14d8066cf7c7", - "occurrences": [ - { - "identity": "pi-doom", - "offset": 1252, - "context": "doomgeneric](https://github.com/ozkl/doomgeneric) for the portable DOOM implementation\n- [pi-doom](https://github.com/badlogic/pi-doom) for the original diffex integration\n", - "classification": "legal notice", - "source": "packages/coding-agent/examples/extensions/doom-overlay/README.md", - "reviewedSourceContexts": [ - "- [pi-doom](https://github.com/badlogic/pi-doom) for the original diffex integration" - ] - }, - { - "identity": "pi-doom", - "offset": 1289, - "context": "doomgeneric) for the portable DOOM implementation\n- [pi-doom](https://github.com/badlogic/pi-doom) for the original diffex integration\n", - "classification": "legal notice", - "source": "packages/coding-agent/examples/extensions/doom-overlay/README.md", - "reviewedSourceContexts": [ - "- [pi-doom](https://github.com/badlogic/pi-doom) for the original diffex integration" - ] - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/api/openai-codex-responses.d.ts.map", - "sha256": "b104d6dfc7aa4ae7de3eeaa869bf90493ec4eb92047311c341e8e1876463a478", - "occurrences": [ - { - "identity": "pi", - "offset": 59693, - "context": "unt-id\\\", accountId);\\n\\theaders.set(\\\"originator\\\", \\\"pi\\\");\\n\\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \\\"pi (browser)\\\";\\n\\theaders.set(\\\"User-Agen", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts", - "reviewedSourceContexts": [ - "\theaders.set(\"originator\", \"pi\");", - "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";" - ] - }, - { - "identity": "pi", - "offset": 59753, - "context": ";\\n\\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \\\"pi (browser)\\\";\\n\\theaders.set(\\\"User-Agent\\\", userAgent);\\n\\treturn headers;\\n}\\n\\nfunction buildSSEH", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts", - "reviewedSourceContexts": [ - "\theaders.set(\"originator\", \"pi\");", - "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";" - ] - } - ], - "decodedSources": [ - { - "path": "../../src/api/openai-codex-responses.ts", - "line": 1608, - "identity": "pi", - "context": "\theaders.set(\"originator\", \"pi\");", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts" - }, - { - "path": "../../src/api/openai-codex-responses.ts", - "line": 1609, - "identity": "pi", - "context": "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts" - }, - { - "path": "../../src/api/openai-codex-responses.ts", - "line": 1609, - "identity": "pi", - "context": "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/api/openai-codex-responses.js", - "sha256": "f0699749b06045244fd6ced26aee4f2627e7218199fd2955b0003fe08592aead", - "occurrences": [ - { - "identity": "pi", - "offset": 52546, - "context": "oken}`);\n headers.set(\"chatgpt-account-id\", accountId);\n headers.set(\"originator\", \"pi\");\n const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (brow", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts", - "reviewedSourceContexts": [ - "\theaders.set(\"originator\", \"pi\");", - "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";" - ] - }, - { - "identity": "pi", - "offset": 52581, - "context": "ccount-id\", accountId);\n headers.set(\"originator\", \"pi\");\n const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";\n headers.set(\"User-Agent\"", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts", - "reviewedSourceContexts": [ - "\theaders.set(\"originator\", \"pi\");", - "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";" - ] - }, - { - "identity": "pi", - "offset": 52640, - "context": ";\n const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";\n headers.set(\"User-Agent\", userAgent);\n return headers;\n}\nfunction buildSSEHeader", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts", - "reviewedSourceContexts": [ - "\theaders.set(\"originator\", \"pi\");", - "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";" - ] - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/api/openai-codex-responses.js.map", - "sha256": "8f59a28de8246a392b4c04d1e06f383788dac6a9fc7e25f6aeaaf5c69d9ff5a4", - "occurrences": [ - { - "identity": "pi", - "offset": 105386, - "context": "unt-id\\\", accountId);\\n\\theaders.set(\\\"originator\\\", \\\"pi\\\");\\n\\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \\\"pi (browser)\\\";\\n\\theaders.set(\\\"User-Agen", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts", - "reviewedSourceContexts": [ - "\theaders.set(\"originator\", \"pi\");", - "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";" - ] - }, - { - "identity": "pi", - "offset": 105446, - "context": ";\\n\\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \\\"pi (browser)\\\";\\n\\theaders.set(\\\"User-Agent\\\", userAgent);\\n\\treturn headers;\\n}\\n\\nfunction buildSSEH", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts", - "reviewedSourceContexts": [ - "\theaders.set(\"originator\", \"pi\");", - "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";" - ] - } - ], - "decodedSources": [ - { - "path": "../../src/api/openai-codex-responses.ts", - "line": 1608, - "identity": "pi", - "context": "\theaders.set(\"originator\", \"pi\");", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts" - }, - { - "path": "../../src/api/openai-codex-responses.ts", - "line": 1609, - "identity": "pi", - "context": "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts" - }, - { - "path": "../../src/api/openai-codex-responses.ts", - "line": 1609, - "identity": "pi", - "context": "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/auth/oauth/openai-codex.d.ts.map", - "sha256": "62de92cb3093bdd6aa53034131ca11b62d55320d4ef64edd2a553b06d559b2cc", - "occurrences": [], - "decodedSources": [ - { - "path": "../../../src/auth/oauth/openai-codex.ts", - "line": 294, - "identity": "pi", - "context": "\toriginator: string = \"pi\",", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/openai-codex.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/auth/oauth/openai-codex.js", - "sha256": "f381c2625dfd23d3f0259343ec9b02cbe6091a2d814710c3cd1a50b554ebecfc", - "occurrences": [ - { - "identity": "pi", - "offset": 8599, - "context": "\n };\n },\n });\n}\nasync function createAuthorizationFlow(originator = \"pi\") {\n const { verifier, challenge } = await generatePKCE();\n const state = createState();\n ", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/openai-codex.ts", - "reviewedSourceContexts": ["\toriginator: string = \"pi\","] - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/auth/oauth/openai-codex.js.map", - "sha256": "52075f74ff389610f3555919b1859c3cfee601bd245bd32ea0b6df8f9453db42", - "occurrences": [], - "decodedSources": [ - { - "path": "../../../src/auth/oauth/openai-codex.ts", - "line": 294, - "identity": "pi", - "context": "\toriginator: string = \"pi\",", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/openai-codex.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/auth/oauth/radius.d.ts.map", - "sha256": "085a79e5c06dd61ed79d887f904c25c7690926d83c604cfa682ef03cf3942fd9", - "occurrences": [ - { - "identity": "pi-gateway", - "offset": 1736, - "context": "\"browser\\\";\\nconst LOGIN_METHOD_DEVICE_CODE = \\\"device-code\\\";\\nconst OAUTH_CLIENT_ID = \\\"pi-gateway\\\";\\nconst OAUTH_SCOPE = \\\"gateway offline_access\\\";\\nconst OAUTH_DEVICE_CODE_GRANT_TYPE = \\\"urn:iet", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/radius.ts", - "reviewedSourceContexts": ["const OAUTH_CLIENT_ID = \"pi-gateway\";"] - } - ], - "decodedSources": [ - { - "path": "../../../src/auth/oauth/radius.ts", - "line": 33, - "identity": "pi-gateway", - "context": "const OAUTH_CLIENT_ID = \"pi-gateway\";", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/radius.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/auth/oauth/radius.js", - "sha256": "72e7293a97495bc82cf55b12b0af70503330153e9018d734aed1fc85b65e1dfb", - "occurrences": [ - { - "identity": "pi-gateway", - "offset": 1217, - "context": "SER = \"browser\";\nconst LOGIN_METHOD_DEVICE_CODE = \"device-code\";\nconst OAUTH_CLIENT_ID = \"pi-gateway\";\nconst OAUTH_SCOPE = \"gateway offline_access\";\nconst OAUTH_DEVICE_CODE_GRANT_TYPE = \"urn:ietf:para", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/radius.ts", - "reviewedSourceContexts": ["const OAUTH_CLIENT_ID = \"pi-gateway\";"] - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/auth/oauth/radius.js.map", - "sha256": "0b903bfaf9a0ad9a33a1e8cc46e433f4dced0dd791bf7334095c246d201e9707", - "occurrences": [ - { - "identity": "pi-gateway", - "offset": 11995, - "context": "\"browser\\\";\\nconst LOGIN_METHOD_DEVICE_CODE = \\\"device-code\\\";\\nconst OAUTH_CLIENT_ID = \\\"pi-gateway\\\";\\nconst OAUTH_SCOPE = \\\"gateway offline_access\\\";\\nconst OAUTH_DEVICE_CODE_GRANT_TYPE = \\\"urn:iet", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/radius.ts", - "reviewedSourceContexts": ["const OAUTH_CLIENT_ID = \"pi-gateway\";"] - } - ], - "decodedSources": [ - { - "path": "../../../src/auth/oauth/radius.ts", - "line": 33, - "identity": "pi-gateway", - "context": "const OAUTH_CLIENT_ID = \"pi-gateway\";", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/radius.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/auth/oauth/xai.d.ts.map", - "sha256": "26a807bf04c00ba129c09a973becd4f5f800ec55d3a1836808b4b375a074c96c", - "occurrences": [], - "decodedSources": [ - { - "path": "../../../src/auth/oauth/xai.ts", - "line": 151, - "identity": "pi", - "context": "\t\t\treferrer: \"pi\",", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/xai.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/auth/oauth/xai.js", - "sha256": "d34783560dc2ac75d6e717b248136eb75a902fbd53b3e56fb9f2ec50c6c793d7", - "occurrences": [ - { - "identity": "pi", - "offset": 4641, - "context": "ODE_URL, {\n client_id: XAI_CLIENT_ID,\n scope: XAI_SCOPE,\n referrer: \"pi\",\n }, signal);\n if (!response.ok) {\n throw requestFailure(\"device authorization\", resp", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/xai.ts", - "reviewedSourceContexts": ["\t\t\treferrer: \"pi\","] - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/auth/oauth/xai.js.map", - "sha256": "56f76e919ab387e41ddaf4f90fcbcd2c2765640134fffb8f05d269630f29ffea", - "occurrences": [], - "decodedSources": [ - { - "path": "../../../src/auth/oauth/xai.ts", - "line": 151, - "identity": "pi", - "context": "\t\t\treferrer: \"pi\",", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/xai.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/providers/radius-config.d.ts", - "sha256": "cc578f2d379758e57327b2b0f29731cee4987108a0c731b30afba5e32cb5b5a3", - "occurrences": [ - { - "identity": "radius.pi.dev", - "offset": 172, - "context": "kingLevelMap } from \"../types.ts\";\nexport declare const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";\nexport type RadiusGatewayModel = {\n id: string;\n name: string;\n reasoning: boolean;\n ", - "classification": "approved integration", - "source": "packages/ai/src/providers/radius-config.ts", - "reviewedSourceContexts": [ - "export const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";" - ] - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/providers/radius-config.d.ts.map", - "sha256": "3ce1218f2ac813e5c459a68a6556e6dc17ab4ee2ffabd32701cfd70e03ebcd3a", - "occurrences": [ - { - "identity": "radius.pi.dev", - "offset": 1417, - "context": "inkingLevelMap } from \\\"../types.ts\\\";\\n\\nexport const DEFAULT_RADIUS_GATEWAY = \\\"https://radius.pi.dev\\\";\\n\\nexport type RadiusGatewayModel = {\\n\\tid: string;\\n\\tname: string;\\n\\treasoning: boolean;\\n\\t", - "classification": "approved integration", - "source": "packages/ai/src/providers/radius-config.ts", - "reviewedSourceContexts": [ - "export const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";" - ] - } - ], - "decodedSources": [ - { - "path": "../../src/providers/radius-config.ts", - "line": 4, - "identity": "radius.pi.dev", - "context": "export const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";", - "classification": "approved integration", - "source": "packages/ai/src/providers/radius-config.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/providers/radius-config.js", - "sha256": "feae85aac33e3cba701c98c6687ce8dd5482ea3b6ff865782b0adcdba88922fa", - "occurrences": [ - { - "identity": "radius.pi.dev", - "offset": 47, - "context": "export const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";\nfunction isRadiusGatewayModel(value) {\n if (typeof value !== \"object\" || value === null || Arr", - "classification": "approved integration", - "source": "packages/ai/src/providers/radius-config.ts", - "reviewedSourceContexts": [ - "export const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";" - ] - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-ai/dist/providers/radius-config.js.map", - "sha256": "160d87badc3d08d39d932f78b690110a37d0d5870f8db1478289262d4bb7eb27", - "occurrences": [ - { - "identity": "radius.pi.dev", - "offset": 2962, - "context": "inkingLevelMap } from \\\"../types.ts\\\";\\n\\nexport const DEFAULT_RADIUS_GATEWAY = \\\"https://radius.pi.dev\\\";\\n\\nexport type RadiusGatewayModel = {\\n\\tid: string;\\n\\tname: string;\\n\\treasoning: boolean;\\n\\t", - "classification": "approved integration", - "source": "packages/ai/src/providers/radius-config.ts", - "reviewedSourceContexts": [ - "export const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";" - ] - } - ], - "decodedSources": [ - { - "path": "../../src/providers/radius-config.ts", - "line": 4, - "identity": "radius.pi.dev", - "context": "export const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";", - "classification": "approved integration", - "source": "packages/ai/src/providers/radius-config.ts" - } - ] - }, - { - "path": "node/node_modules/@diffexai/diffex-client/CHANGELOG.md", - "sha256": "bad7ff4544c33e36233e650520efcb53cfd456efd2ee44ce7863f56600502474", - "historicalOccurrences": 1, - "review": "Immutable changelog history preserved under repository instructions; no current runtime identity or egress permission." - }, - { - "path": "node/node_modules/@diffexai/diffex-server/CHANGELOG.md", - "sha256": "0d7b67432c4005a04a4ff64e039b973598a8337e1eee652c4756c4b3cc61c66a", - "historicalOccurrences": 3, - "review": "Immutable changelog history preserved under repository instructions; no current runtime identity or egress permission." - }, - { - "path": "node/node_modules/@diffexai/diffex-session-backend-sqlite-node/CHANGELOG.md", - "sha256": "51c2f2c8cfdbcd367380730b45008e60e0a456e717b45fe3b917e4822ae42602", - "historicalOccurrences": 4, - "review": "Immutable changelog history preserved under repository instructions; no current runtime identity or egress permission." - }, - { - "path": "binaries/darwin-arm64/CHANGELOG.md", - "sha256": "00b1501c588c75cd7c12e9769e0f8e1c14b87663b608a8da3a1e87fc3acb185c", - "historicalOccurrences": 1824, - "review": "Immutable changelog history preserved under repository instructions; no current runtime identity or egress permission." - }, - { - "path": "binaries/darwin-arm64/diffex", - "sha256": "8e4b67e1453c3af626ac082c5f9379c9c11c398c8c7ad3004af70d5e1bf9c416", - "occurrences": [ - { - "identity": "radius.pi.dev", - "offset": 60387079, - "context": "Radius config from ${gateway}`);\n return config;\n}\nvar DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";\n\n// ../../node_modules/isexe/windows.js\nvar require_windows = __commonJS((exports, module) => {\n ", - "classification": "approved integration", - "source": "packages/ai/src/providers/radius-config.ts", - "reviewedSourceContexts": [ - "export const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";" - ] - }, - { - "identity": "Pi", - "offset": 61046273, - "context": " let e3 = gt(t2, a2, false, \"?>\");\n if (!e3)\n throw new Error(\"Pi Tag is not closed.\");\n i2 = this.saveTextToParentTag(i2, n2, this.readonlyMatcher);\n ", - "classification": "third-party context, outside Diffex product identity", - "dependency": "fast-xml-parser 5.7.3", - "reason": "XML processing-instruction error; PI is XML terminology." - }, - { - "identity": "pi", - "offset": 65456584, - "context": " ${token}`);\n headers.set(\"chatgpt-account-id\", accountId);\n headers.set(\"originator\", \"pi\");\n const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browse", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts", - "reviewedSourceContexts": [ - "\theaders.set(\"originator\", \"pi\");", - "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";" - ] - }, - { - "identity": "pi", - "offset": 65456617, - "context": "pt-account-id\", accountId);\n headers.set(\"originator\", \"pi\");\n const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";\n headers.set(\"User-Agent\", ", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts", - "reviewedSourceContexts": [ - "\theaders.set(\"originator\", \"pi\");", - "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";" - ] - }, - { - "identity": "pi", - "offset": 65456676, - "context": "\");\n const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";\n headers.set(\"User-Agent\", userAgent);\n return headers;\n}\nfunction buildSSEHeaders(in", - "classification": "approved integration", - "source": "packages/ai/src/api/openai-codex-responses.ts", - "reviewedSourceContexts": [ - "\theaders.set(\"originator\", \"pi\");", - "\tconst userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";" - ] - }, - { - "identity": "pi.dev", - "offset": 66935201, - "context": "els = published;\n }\n });\n }\n };\n}\nvar DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\", REMOTE_CATALOG_REFRESH_INTERVAL_MS;\nvar init_remote_catalog_provider = __esm(() => {\n init_confi", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/remote-catalog-provider.ts", - "reviewedSourceContexts": [ - "const DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";", - "/** Add a persisted pi.dev catalog overlay to a static built-in provider. */" - ] - }, - { - "identity": "pi", - "offset": 68605984, - "context": "|0\",\n \"devnull\",\n \"false\",\n \"im\",\n \"missing\",\n \"nothing\",\n \"pi\",\n \"stderr\",\n \"stdin\",\n \"stdout\",\n \"true\",\n \"undef\",\n \"\\u03C0\",\n ", - "classification": "third-party context, outside Diffex product identity", - "dependency": "highlight.js 10.7.3", - "reason": "Julia mathematical constant in syntax-highlighting vocabulary." - }, - { - "identity": "Pi", - "offset": 68781371, - "context": " \"PetersenGraph\",\n \"PhaseMargins\",\n \"PhaseRange\",\n \"PhysicalSystemData\",\n \"Pi\",\n \"Pick\",\n \"PIDData\",\n \"PIDDerivativeFilter\",\n \"PIDFeedforward\",\n \"PIDTune\",\n \"P", - "classification": "third-party context, outside Diffex product identity", - "dependency": "highlight.js 10.7.3", - "reason": "Mathematica mathematical constant in syntax-highlighting vocabulary." - }, - { - "identity": "pi", - "offset": 69143068, - "context": " \"pareto_type_2_lcdf\",\n \"pareto_type_2_lpdf\",\n \"pareto_type_2_rng\",\n \"pi\",\n \"poisson_cdf\",\n \"poisson_lccdf\",\n \"poisson_lcdf\",\n \"poisson_log_lpmf\",\n ", - "classification": "third-party context, outside Diffex product identity", - "dependency": "highlight.js 10.7.3", - "reason": "Stan mathematical constant in syntax-highlighting vocabulary." - }, - { - "identity": "pi", - "offset": 70428755, - "context": "NI_CLI\"]], [\"codex\", [\"CODEX_SANDBOX\", \"CODEX_THREAD_ID\"]], [\"opencode\", [\"OPENCODE\"]], [\"pi\", [dist_i(\"PATH\", /\\.pi[\\\\/]agent/)]], [\"auggie\", [\"AUGMENT_AGENT\"]], [\"goose\", [\"GOOSE_PROVIDER\"]]", - "classification": "third-party context, outside Diffex product identity", - "dependency": "jiti 2.7.0 bundled std-env", - "reason": "Detects an external Pi agent from PATH as one of several external runtimes; does not identify Diffex as Pi or depend on a Pi package, endpoint, credential, or organization." - }, - { - "identity": "pi.dev", - "offset": 73489727, - "context": "return;\n }\n if (isOpenRouterModel(model)) {\n return {\n \"HTTP-Referer\": \"https://pi.dev\",\n \"X-OpenRouter-Title\": \"pi\",\n \"X-OpenRouter-Categories\": \"cli-agent\"\n };\n }\n if (i", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"HTTP-Referer\": \"https://pi.dev\"," - ] - }, - { - "identity": "pi", - "offset": 73489765, - "context": "del)) {\n return {\n \"HTTP-Referer\": \"https://pi.dev\",\n \"X-OpenRouter-Title\": \"pi\",\n \"X-OpenRouter-Categories\": \"cli-agent\"\n };\n }\n if (isNvidiaNimModel(model)) {\n retu", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"X-OpenRouter-Title\": \"pi\",", - "\treturn { \"x-opencode-session\": sessionId, \"x-opencode-client\": \"pi\" };" - ] - }, - { - "identity": "Pi", - "offset": 73489906, - "context": " };\n }\n if (isNvidiaNimModel(model)) {\n return {\n \"X-BILLING-INVOKE-ORIGIN\": \"Pi\"\n };\n }\n if (isCloudflareModel(model)) {\n return {\n \"User-Agent\": \"pi-coding-agent\"\n ", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"X-BILLING-INVOKE-ORIGIN\": \"Pi\"," - ] - }, - { - "identity": "pi-coding-agent", - "offset": 73489989, - "context": "GIN\": \"Pi\"\n };\n }\n if (isCloudflareModel(model)) {\n return {\n \"User-Agent\": \"pi-coding-agent\"\n };\n }\n return;\n}\nfunction getSessionHeaders(model, sessionId) {\n if (!sessionId)\n return", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"User-Agent\": \"pi-coding-agent\"," - ] - }, - { - "identity": "pi", - "offset": 73490309, - "context": "OST)) {\n return;\n }\n return { \"x-opencode-session\": sessionId, \"x-opencode-client\": \"pi\" };\n}\nfunction mergeProviderAttributionHeaders(model, settingsManager, sessionId, ...headerSources)", - "classification": "approved integration", - "source": "packages/coding-agent/src/core/provider-attribution.ts", - "reviewedSourceContexts": [ - "\t\t\t\"X-OpenRouter-Title\": \"pi\",", - "\treturn { \"x-opencode-session\": sessionId, \"x-opencode-client\": \"pi\" };" - ] - }, - { - "identity": "pi", - "offset": 74673979, - "context": "Body}` : \"\"}`\n };\n }\n });\n}\nasync function createAuthorizationFlow(originator = \"pi\") {\n const { verifier, challenge } = await generatePKCE();\n const state = createState();\n const ", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/openai-codex.ts", - "reviewedSourceContexts": ["\toriginator: string = \"pi\","] - }, - { - "identity": "pi-gateway", - "offset": 74690611, - "context": "BROWSER = \"browser\";\nvar LOGIN_METHOD_DEVICE_CODE = \"device-code\";\nvar OAUTH_CLIENT_ID = \"pi-gateway\";\nvar OAUTH_SCOPE = \"gateway offline_access\";\nvar OAUTH_DEVICE_CODE_GRANT_TYPE = \"urn:ietf:params:o", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/radius.ts", - "reviewedSourceContexts": ["const OAUTH_CLIENT_ID = \"pi-gateway\";"] - }, - { - "identity": "pi", - "offset": 74703952, - "context": "XAI_DEVICE_CODE_URL, {\n client_id: XAI_CLIENT_ID,\n scope: XAI_SCOPE,\n referrer: \"pi\"\n }, signal);\n if (!response.ok) {\n throw requestFailure(\"device authorization\", response);\n ", - "classification": "approved integration", - "source": "packages/ai/src/auth/oauth/xai.ts", - "reviewedSourceContexts": ["\t\t\treferrer: \"pi\","] - } - ] - }, - { - "path": "binaries/darwin-arm64/examples/extensions/doom-overlay/README.md", - "sha256": "76502ed664f76f32b10fda35760b5d2cde407490d3567c2a4c6f14d8066cf7c7", - "occurrences": [ - { - "identity": "pi-doom", - "offset": 1252, - "context": "doomgeneric](https://github.com/ozkl/doomgeneric) for the portable DOOM implementation\n- [pi-doom](https://github.com/badlogic/pi-doom) for the original diffex integration\n", - "classification": "legal notice", - "source": "packages/coding-agent/examples/extensions/doom-overlay/README.md", - "reviewedSourceContexts": [ - "- [pi-doom](https://github.com/badlogic/pi-doom) for the original diffex integration" - ] - }, - { - "identity": "pi-doom", - "offset": 1289, - "context": "doomgeneric) for the portable DOOM implementation\n- [pi-doom](https://github.com/badlogic/pi-doom) for the original diffex integration\n", - "classification": "legal notice", - "source": "packages/coding-agent/examples/extensions/doom-overlay/README.md", - "reviewedSourceContexts": [ - "- [pi-doom](https://github.com/badlogic/pi-doom) for the original diffex integration" - ] - } - ] - } - ] -} diff --git a/docs/release/candidate-rehearsal.json b/docs/release/candidate-rehearsal.json deleted file mode 100644 index d3afad8d9..000000000 --- a/docs/release/candidate-rehearsal.json +++ /dev/null @@ -1,1157 +0,0 @@ -{ - "date": "2026-09-13", - "status": "accepted-at-configured-boundaries-with-pending-live-client-matrix", - "productionAcceptance": false, - "cliSource": { - "repository": "diffexai/diffex", - "commit": "af5e64d09f95f5db28883421599769dbdf8bc796", - "tree": "f35d4481684bdaa942c3eb8c6b8aa73cadd13f15", - "lockSha256": "d835f2dbd577807a540bc59d85131d7ec849044bce94e49c2757ee12a7983a20", - "policySha256": "fdbd700159c01b9643cbe57c5d7d4b809568c94609a8ebd766ae91e77f4d4853" - }, - "cloudSourceCommit": "4a87c4492804f68fea380debc43cafa86d0d1fa5", - "workflowRestrictionCommit": "16f94e40a8d6e73414c226e216891da62eed8332", - "version": "0.2.1", - "artifacts": [ - { - "name": "@diffexai/diffex", - "version": "0.2.1", - "filename": "diffexai-diffex-0.2.1.tgz", - "size": 1878842, - "sha256": "8c1a93885226b877c075d49fb4b610b62f4ef8427b13002ff7300920a0786054", - "integrity": "sha512-zgDL8WC21g38g6zfpEi0HlxNG66v06ZZMW0x+5CvbiglJPd7BHdI73tvrFFMgsvtXdIQLxVi3FVLlKYeJzcooQ==" - } - ], - "snapshot": { - "filename": "source.tar.gz", - "sha256": "c01cf701399740a9a6489de02a50d624942b1c309b9c881d539c1eaa731af852" - }, - "buildToolchain": { - "node": "v22.19.0", - "npm": "11.16.0", - "platform": "linux", - "arch": "x64" - }, - "manifestSha256": "a46071be950e7bfb5215efdd7d5160b785dd3e0baf62666983aa0888d1bd74d9", - "inputsSha256": "d402446eb13f3bee4ff80cec525254a2589609f9f75d3003797355ca29030e17", - "scope": { - "packages": [ - "@diffexai/diffex" - ], - "binaryPlatforms": [], - "managers": [ - "npm", - "bun" - ] - }, - "workflows": { - "nativeAcceptance": "https://github.com/diffexai/diffex/actions/runs/34784995881", - "runtime": "https://github.com/diffexai/diffex/actions/runs/34784999538", - "stagingStorageRecovery": "https://github.com/diffexai/diffex/actions/runs/34785182715", - "reproducedStartupFailure": "https://github.com/diffexai/diffex/actions/runs/34784741853", - "environmentRejectedRetry": "https://github.com/diffexai/diffex/actions/runs/34784991684" - }, - "commands": [ - { - "cwd": "CLI", - "command": "npm run check:model-data", - "exitCode": 0 - }, - { - "cwd": "CLI", - "command": "npm run build:offline", - "exitCode": 0 - }, - { - "cwd": "CLI", - "command": "npm run check", - "exitCode": 0 - }, - { - "cwd": "CLI", - "command": "./test.sh", - "exitCode": 0, - "scriptTestsPassed": 248, - "vitestPassed": 4948, - "vitestSkipped": 893 - }, - { - "cwd": "CLI/packages/coding-agent", - "command": "node ../../node_modules/vitest/dist/cli.js --run test/version-update-e2e.test.ts", - "exitCode": 0, - "testsPassed": 32 - }, - { - "cwd": "CLI", - "command": "actionlint .github/workflows/verify-release-publication.yml", - "exitCode": 0 - }, - { - "cwd": "CLI", - "command": "node scripts/release-artifacts.mjs verify /tmp/diffex-candidate-rehearsal/final-artifacts", - "exitCode": 0 - }, - { - "cwd": "CLI", - "command": "node scripts/artifact-acceptance.mjs /tmp/diffex-candidate-rehearsal/final-artifacts /tmp/diffex-candidate-rehearsal/local-acceptance", - "exitCode": 0 - }, - { - "cwd": "CLI", - "command": "python3 scripts/check-distribution-payload.py /tmp/diffex-candidate-rehearsal/final-artifacts/diffexai-diffex-0.2.1.tgz --name @diffexai/diffex --version 0.2.1", - "exitCode": 0 - }, - { - "cwd": "Cloud", - "command": "npm run check", - "exitCode": 0, - "unitPassed": 546, - "integrationPassed": 23 - }, - { - "cwd": "Cloud/services/public-api", - "command": "npm run check", - "exitCode": 0, - "testsPassed": 352 - }, - { - "cwd": "Cloud", - "command": "node scripts/check-release-contract.mjs /Users/jialeyu/Projects/diffex-new", - "exitCode": 0 - } - ], - "native": [ - { - "artifactName": "native-acceptance-macos-15-af5e64d09f95f5db28883421599769dbdf8bc796", - "platform": "darwin", - "arch": "arm64", - "node": "v22.19.0", - "npm": "11.16.0", - "bun": "1.3.11", - "status": "passed", - "cleanup": true, - "packages": [ - { - "manager": "npm", - "managerVersion": "11.16.0", - "name": "@diffexai/diffex", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "operation": "passed", - "localDependencies": [ - "@diffexai/diffex" - ] - }, - { - "manager": "bun", - "managerVersion": "1.3.11", - "name": "@diffexai/diffex", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "operation": "passed", - "localDependencies": [ - "@diffexai/diffex" - ] - } - ], - "processes": [ - { - "manager": "npm", - "server": "Excluded from CLI distribution; hosted runtime remains private", - "terminal": "passed", - "helpers": { - "fd": "fd 10.5.0", - "rg": "ripgrep 15.2.0\n\nfeatures:+pcre2\nsimd(compile):+NEON\nsimd(runtime):+NEON\n\nPCRE2 10.45 is available (JIT is available)" - } - }, - { - "manager": "bun", - "server": "Excluded from CLI distribution; hosted runtime remains private", - "terminal": "passed", - "helpers": { - "fd": "fd 10.5.0", - "rg": "ripgrep 15.2.0\n\nfeatures:+pcre2\nsimd(compile):+NEON\nsimd(runtime):+NEON\n\nPCRE2 10.45 is available (JIT is available)" - } - } - ], - "lifecycleChecks": [ - { - "invalid": { - "status": 200, - "body": "{" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 200, - "body": "{\"packageName\":\"@foreign/cli\",\"version\":\"0.2.1\"}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 200, - "body": "{\"packageName\":\"@diffexai/diffex\",\"version\":\"1.2.3-beta.1\"}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 302, - "body": "{}", - "headers": { - "location": "https://foreign.invalid" - } - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 503, - "body": "{}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 404, - "body": "{\"error\":\"no_published_release\"}" - }, - "installationUnchanged": true - }, - { - "upgrade": "0.0.0 metadata fixture to reviewed candidate", - "realNpm": true - }, - { - "fault": "corrupt", - "installationUnchanged": true, - "movedBackup": false, - "pinnedRepair": "passed" - }, - { - "fault": "interrupt", - "installationUnchanged": false, - "movedBackup": false, - "pinnedRepair": "passed" - }, - { - "uninstall": "passed", - "userDataRetained": true, - "projectDataRetained": true - }, - { - "manager": "bun", - "install": "passed", - "upgrade": "metadata fixture to reviewed candidate", - "foreignIdentityRejected": true, - "uninstall": "passed", - "userDataRetained": true - } - ], - "acceptanceSha256": "18867b91576826c1c96fe751e433f6ad47933136d3da9949ab0896b883961cfb", - "lifecycleSha256": "15f73d350b12b060b423716e59a618b3a66a38e72e512207eb7976d30255da2b" - }, - { - "artifactName": "native-acceptance-macos-15-intel-af5e64d09f95f5db28883421599769dbdf8bc796", - "platform": "darwin", - "arch": "x64", - "node": "v22.19.0", - "npm": "11.16.0", - "bun": "1.3.11", - "status": "passed", - "cleanup": true, - "packages": [ - { - "manager": "npm", - "managerVersion": "11.16.0", - "name": "@diffexai/diffex", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "operation": "passed", - "localDependencies": [ - "@diffexai/diffex" - ] - }, - { - "manager": "bun", - "managerVersion": "1.3.11", - "name": "@diffexai/diffex", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "operation": "passed", - "localDependencies": [ - "@diffexai/diffex" - ] - } - ], - "processes": [ - { - "manager": "npm", - "server": "Excluded from CLI distribution; hosted runtime remains private", - "terminal": "passed", - "helpers": { - "fd": "fd 10.4.2", - "rg": "ripgrep 15.2.0\n\nfeatures:+pcre2\nsimd(compile):+SSE2,+SSSE3,-AVX2\nsimd(runtime):+SSE2,+SSSE3,+AVX2\n\nPCRE2 10.45 is available (JIT is available)" - } - }, - { - "manager": "bun", - "server": "Excluded from CLI distribution; hosted runtime remains private", - "terminal": "passed", - "helpers": { - "fd": "fd 10.4.2", - "rg": "ripgrep 15.2.0\n\nfeatures:+pcre2\nsimd(compile):+SSE2,+SSSE3,-AVX2\nsimd(runtime):+SSE2,+SSSE3,+AVX2\n\nPCRE2 10.45 is available (JIT is available)" - } - } - ], - "lifecycleChecks": [ - { - "invalid": { - "status": 200, - "body": "{" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 200, - "body": "{\"packageName\":\"@foreign/cli\",\"version\":\"0.2.1\"}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 200, - "body": "{\"packageName\":\"@diffexai/diffex\",\"version\":\"1.2.3-beta.1\"}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 302, - "body": "{}", - "headers": { - "location": "https://foreign.invalid" - } - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 503, - "body": "{}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 404, - "body": "{\"error\":\"no_published_release\"}" - }, - "installationUnchanged": true - }, - { - "upgrade": "0.0.0 metadata fixture to reviewed candidate", - "realNpm": true - }, - { - "fault": "corrupt", - "installationUnchanged": true, - "movedBackup": false, - "pinnedRepair": "passed" - }, - { - "fault": "interrupt", - "installationUnchanged": false, - "movedBackup": true, - "pinnedRepair": "passed" - }, - { - "uninstall": "passed", - "userDataRetained": true, - "projectDataRetained": true - }, - { - "manager": "bun", - "install": "passed", - "upgrade": "metadata fixture to reviewed candidate", - "foreignIdentityRejected": true, - "uninstall": "passed", - "userDataRetained": true - } - ], - "acceptanceSha256": "02c4fa81e4645075a95686600a41c832bb675ea4fd15b36542f27a36f6987c0f", - "lifecycleSha256": "2dcca1cb9ecb18718b9a782761205a9254feedf7f2d3cbcf09af3bffc4f4c462" - }, - { - "artifactName": "native-acceptance-ubuntu-24.04-af5e64d09f95f5db28883421599769dbdf8bc796", - "platform": "linux", - "arch": "x64", - "node": "v22.19.0", - "npm": "11.16.0", - "bun": "1.3.11", - "status": "passed", - "cleanup": true, - "packages": [ - { - "manager": "npm", - "managerVersion": "11.16.0", - "name": "@diffexai/diffex", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "operation": "passed", - "localDependencies": [ - "@diffexai/diffex" - ] - }, - { - "manager": "bun", - "managerVersion": "1.3.11", - "name": "@diffexai/diffex", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "operation": "passed", - "localDependencies": [ - "@diffexai/diffex" - ] - } - ], - "processes": [ - { - "manager": "npm", - "server": "Excluded from CLI distribution; hosted runtime remains private", - "terminal": "passed", - "helpers": { - "fd": "fdfind 9.0.0", - "rg": "ripgrep 14.1.0\n\nfeatures:-simd-accel,+pcre2\nsimd(compile):+SSE2,-SSSE3,-AVX2\nsimd(runtime):+SSE2,+SSSE3,+AVX2\n\nPCRE2 10.42 is available (JIT is available)" - } - }, - { - "manager": "bun", - "server": "Excluded from CLI distribution; hosted runtime remains private", - "terminal": "passed", - "helpers": { - "fd": "fdfind 9.0.0", - "rg": "ripgrep 14.1.0\n\nfeatures:-simd-accel,+pcre2\nsimd(compile):+SSE2,-SSSE3,-AVX2\nsimd(runtime):+SSE2,+SSSE3,+AVX2\n\nPCRE2 10.42 is available (JIT is available)" - } - } - ], - "lifecycleChecks": [ - { - "invalid": { - "status": 200, - "body": "{" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 200, - "body": "{\"packageName\":\"@foreign/cli\",\"version\":\"0.2.1\"}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 200, - "body": "{\"packageName\":\"@diffexai/diffex\",\"version\":\"1.2.3-beta.1\"}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 302, - "body": "{}", - "headers": { - "location": "https://foreign.invalid" - } - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 503, - "body": "{}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 404, - "body": "{\"error\":\"no_published_release\"}" - }, - "installationUnchanged": true - }, - { - "upgrade": "0.0.0 metadata fixture to reviewed candidate", - "realNpm": true - }, - { - "fault": "corrupt", - "installationUnchanged": true, - "movedBackup": false, - "pinnedRepair": "passed" - }, - { - "fault": "interrupt", - "installationUnchanged": false, - "movedBackup": false, - "pinnedRepair": "passed" - }, - { - "uninstall": "passed", - "userDataRetained": true, - "projectDataRetained": true - }, - { - "manager": "bun", - "install": "passed", - "upgrade": "metadata fixture to reviewed candidate", - "foreignIdentityRejected": true, - "uninstall": "passed", - "userDataRetained": true - } - ], - "acceptanceSha256": "a6faf3103c394140f0ce09ab39d90f0d953c1eca80db2188ed55ceb57da2c211", - "lifecycleSha256": "e4b96b0d56780164310412c5e570b155eb1089ca2c08de3d3ce73b3ebe64b8db" - }, - { - "artifactName": "native-acceptance-ubuntu-24.04-arm-af5e64d09f95f5db28883421599769dbdf8bc796", - "platform": "linux", - "arch": "arm64", - "node": "v22.19.0", - "npm": "11.16.0", - "bun": "1.3.11", - "status": "passed", - "cleanup": true, - "packages": [ - { - "manager": "npm", - "managerVersion": "11.16.0", - "name": "@diffexai/diffex", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "operation": "passed", - "localDependencies": [ - "@diffexai/diffex" - ] - }, - { - "manager": "bun", - "managerVersion": "1.3.11", - "name": "@diffexai/diffex", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "operation": "passed", - "localDependencies": [ - "@diffexai/diffex" - ] - } - ], - "processes": [ - { - "manager": "npm", - "server": "Excluded from CLI distribution; hosted runtime remains private", - "terminal": "passed", - "helpers": { - "fd": "fdfind 9.0.0", - "rg": "ripgrep 14.1.0\n\nfeatures:-simd-accel,+pcre2\nsimd(compile):+NEON\nsimd(runtime):+NEON\n\nPCRE2 10.42 is available (JIT is available)" - } - }, - { - "manager": "bun", - "server": "Excluded from CLI distribution; hosted runtime remains private", - "terminal": "passed", - "helpers": { - "fd": "fdfind 9.0.0", - "rg": "ripgrep 14.1.0\n\nfeatures:-simd-accel,+pcre2\nsimd(compile):+NEON\nsimd(runtime):+NEON\n\nPCRE2 10.42 is available (JIT is available)" - } - } - ], - "lifecycleChecks": [ - { - "invalid": { - "status": 200, - "body": "{" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 200, - "body": "{\"packageName\":\"@foreign/cli\",\"version\":\"0.2.1\"}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 200, - "body": "{\"packageName\":\"@diffexai/diffex\",\"version\":\"1.2.3-beta.1\"}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 302, - "body": "{}", - "headers": { - "location": "https://foreign.invalid" - } - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 503, - "body": "{}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 404, - "body": "{\"error\":\"no_published_release\"}" - }, - "installationUnchanged": true - }, - { - "upgrade": "0.0.0 metadata fixture to reviewed candidate", - "realNpm": true - }, - { - "fault": "corrupt", - "installationUnchanged": true, - "movedBackup": false, - "pinnedRepair": "passed" - }, - { - "fault": "interrupt", - "installationUnchanged": false, - "movedBackup": false, - "pinnedRepair": "passed" - }, - { - "uninstall": "passed", - "userDataRetained": true, - "projectDataRetained": true - }, - { - "manager": "bun", - "install": "passed", - "upgrade": "metadata fixture to reviewed candidate", - "foreignIdentityRejected": true, - "uninstall": "passed", - "userDataRetained": true - } - ], - "acceptanceSha256": "d767a0be8c8d72da456242f495ccd1c37e35db741c10f5df8a65f7e69b922b41", - "lifecycleSha256": "6a9668825d8c3fbe3c4546cdbd8aaf7782c6df05c5b08bbc4873f4ba2ea5d55d" - } - ], - "local": { - "platform": "darwin", - "arch": "arm64", - "osVersion": "macOS 26.1 (25B78)", - "node": "v22.19.0", - "npm": "10.9.3", - "bun": "1.3.11", - "status": "passed", - "cleanup": true, - "lifecycleChecks": [ - { - "invalid": { - "status": 200, - "body": "{" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 200, - "body": "{\"packageName\":\"@foreign/cli\",\"version\":\"0.2.1\"}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 200, - "body": "{\"packageName\":\"@diffexai/diffex\",\"version\":\"1.2.3-beta.1\"}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 302, - "body": "{}", - "headers": { - "location": "https://foreign.invalid" - } - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 503, - "body": "{}" - }, - "installationUnchanged": true - }, - { - "invalid": { - "status": 404, - "body": "{\"error\":\"no_published_release\"}" - }, - "installationUnchanged": true - }, - { - "upgrade": "0.0.0 metadata fixture to reviewed candidate", - "realNpm": true - }, - { - "fault": "corrupt", - "installationUnchanged": true, - "movedBackup": false, - "pinnedRepair": "passed" - }, - { - "fault": "interrupt", - "installationUnchanged": false, - "movedBackup": true, - "pinnedRepair": "passed" - }, - { - "uninstall": "passed", - "userDataRetained": true, - "projectDataRetained": true - }, - { - "manager": "bun", - "install": "passed", - "upgrade": "metadata fixture to reviewed candidate", - "foreignIdentityRejected": true, - "uninstall": "passed", - "userDataRetained": true - } - ], - "acceptanceSha256": "2b0dfcb6aeaff9a772fa2b14caaa2ad301ae4328eddbe849c552bc082c0ff61c", - "lifecycleSha256": "3a46b7023742e199b864cdb59cc5dcd3b867fdc62ef8c8e517bab05719e6a11b", - "terminalSmokes": { - "npm": [ - { - "runtime": "consumer", - "command": [ - "--version" - ], - "exitCode": 0 - }, - { - "runtime": "consumer", - "command": [ - "--help" - ], - "exitCode": 0 - }, - { - "runtime": "consumer", - "command": [ - "auth", - "--help" - ], - "exitCode": 0 - }, - { - "runtime": "consumer", - "command": [ - "--list-models" - ], - "exitCode": 0, - "expected": "fixture" - }, - { - "runtime": "consumer", - "command": [ - "-p", - "Reply with the fixture confirmation." - ], - "exitCode": 0, - "expected": "handoff-fixture-ok" - }, - { - "runtime": "consumer", - "command": "interactive prompt, login selector, exit", - "exitCode": 0, - "events": [ - { - "type": "inference", - "provider": "handoff" - }, - { - "type": "request", - "url": "https://api.diffex.ai/api/latest-version" - }, - { - "type": "inference", - "provider": "handoff" - } - ], - "accounts": [ - "OpenAI Codex", - "Radius", - "xAI" - ], - "cleanup": "process exited and isolated directories removed" - } - ], - "bun": [ - { - "runtime": "consumer", - "command": [ - "--version" - ], - "exitCode": 0 - }, - { - "runtime": "consumer", - "command": [ - "--help" - ], - "exitCode": 0 - }, - { - "runtime": "consumer", - "command": [ - "auth", - "--help" - ], - "exitCode": 0 - }, - { - "runtime": "consumer", - "command": [ - "--list-models" - ], - "exitCode": 0, - "expected": "fixture" - }, - { - "runtime": "consumer", - "command": [ - "-p", - "Reply with the fixture confirmation." - ], - "exitCode": 0, - "expected": "handoff-fixture-ok" - }, - { - "runtime": "consumer", - "command": "interactive prompt, login selector, exit", - "exitCode": 0, - "events": [ - { - "type": "inference", - "provider": "handoff" - }, - { - "type": "request", - "url": "https://api.diffex.ai/api/latest-version" - }, - { - "type": "inference", - "provider": "handoff" - } - ], - "accounts": [ - "OpenAI Codex", - "Radius", - "xAI" - ], - "cleanup": "process exited and isolated directories removed" - } - ] - } - }, - "runtime": { - "imageId": "sha256:383b8f6070db8e8a8a636b91e323065d1a022535ee1b003557ee82d55f2fbb05", - "archiveSha256": "22546e4a9f004f06615055b6c876261dd24c62bdef0e6e11a00659d368c44222", - "smoke": { - "status": "passed", - "platform": "linux", - "architecture": "x64", - "node": "v24.16.0", - "startup": true, - "anonymousRejected": true, - "wrongCredentialRejected": true, - "foreignSessionRejected": true, - "promptReply": "mock-provider", - "usage": "mock-endpoint", - "restartPersistence": true, - "shutdown": true, - "externalNetwork": "disabled by container", - "sourceCommit": "af5e64d09f95f5db28883421599769dbdf8bc796" - }, - "payload": { - "status": "passed", - "projectFiles": 1414, - "workspaces": 12, - "documentation": [ - "docs/compaction.md", - "docs/containerization.md", - "docs/custom-provider.md", - "docs/environment-variables.md", - "docs/evolution.md", - "docs/extensions.md", - "docs/images/interactive-mode.png", - "docs/images/tree-view.png", - "docs/installation.md", - "docs/json.md", - "docs/keybindings.md", - "docs/llama-cpp.md", - "docs/models.md", - "docs/packages.md", - "docs/prompt-templates.md", - "docs/providers.md", - "docs/quickstart.md", - "docs/rpc.md", - "docs/sdk.md", - "docs/security.md", - "docs/session-format.md", - "docs/sessions.md", - "docs/settings.md", - "docs/shell-aliases.md", - "docs/skills.md", - "docs/terminal-setup.md", - "docs/themes.md", - "docs/tmux.md", - "docs/tui.md", - "docs/usage.md" - ], - "sourceCommit": "af5e64d09f95f5db28883421599769dbdf8bc796", - "architecture": "amd64" - }, - "registryPublished": false, - "providerResourcesCreated": false - }, - "identityScan": { - "sourceFiles": 915, - "sourceOccurrences": 17, - "artifactFiles": 9, - "artifactOccurrences": 16, - "review": "Every artifact match maps to approved OAuth, Radius, provider attribution, or model catalog source entries.", - "files": [ - { - "path": "package/dist/node_modules/@diffexai/diffex-ai/dist/api/openai-codex-responses.js", - "sha256": "26fa632e8e492396357cd72adb94fad316c5ab65426b65e85b7cb4031250f31b", - "matches": [ - { - "identity": "pi", - "context": "oken}`);\n headers.set(\"chatgpt-account-id\", accountId);\n headers.set(\"originator\", \"pi\");\n const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (brow" - }, - { - "identity": "pi", - "context": "ccount-id\", accountId);\n headers.set(\"originator\", \"pi\");\n const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";\n headers.set(\"User-Agent\"" - }, - { - "identity": "pi", - "context": ";\n const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : \"pi (browser)\";\n headers.set(\"User-Agent\", userAgent);\n return headers;\n}\nfunction buildSSEHeader" - } - ] - }, - { - "path": "package/dist/node_modules/@diffexai/diffex-ai/dist/auth/oauth/openai-codex.js", - "sha256": "c090c687c3b1a0ba989911318b4eba4717685eef8fa6252c090a0b8c5a83ec81", - "matches": [ - { - "identity": "pi", - "context": "\n };\n },\n });\n}\nasync function createAuthorizationFlow(originator = \"pi\") {\n const { verifier, challenge } = await generatePKCE();\n const state = createState();\n " - } - ] - }, - { - "path": "package/dist/core/provider-attribution.js", - "sha256": "0a8c1bb97930191739b9042fcd378dc4c9c50b24ceb23b003fc978ee11bbc9b1", - "matches": [ - { - "identity": "pi.dev", - "context": " if (isOpenRouterModel(model)) {\n return {\n \"HTTP-Referer\": \"https://pi.dev\",\n \"X-OpenRouter-Title\": \"pi\",\n \"X-OpenRouter-Categories\": \"cli-agent\",\n " - }, - { - "identity": "pi", - "context": "return {\n \"HTTP-Referer\": \"https://pi.dev\",\n \"X-OpenRouter-Title\": \"pi\",\n \"X-OpenRouter-Categories\": \"cli-agent\",\n };\n }\n if (isNvidiaNimModel(mod" - }, - { - "identity": "Pi", - "context": " if (isNvidiaNimModel(model)) {\n return {\n \"X-BILLING-INVOKE-ORIGIN\": \"Pi\",\n };\n }\n if (isCloudflareModel(model)) {\n return {\n \"User-Agent\": \"" - }, - { - "identity": "pi-coding-agent", - "context": " };\n }\n if (isCloudflareModel(model)) {\n return {\n \"User-Agent\": \"pi-coding-agent\",\n };\n }\n return undefined;\n}\nfunction getSessionHeaders(model, sessionId) {\n if (!" - }, - { - "identity": "pi", - "context": "turn undefined;\n }\n return { \"x-opencode-session\": sessionId, \"x-opencode-client\": \"pi\" };\n}\nexport function mergeProviderAttributionHeaders(model, settingsManager, sessionId, ...headerS" - } - ] - }, - { - "path": "package/dist/node_modules/@diffexai/diffex-ai/dist/providers/radius-config.js", - "sha256": "a7ed64608190af2d66b84263831e36fd544adfb6f2c3abc5cb699128b5f7d48c", - "matches": [ - { - "identity": "radius.pi.dev", - "context": "export const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";\nfunction isRadiusGatewayModel(value) {\n if (typeof value !== \"object\" || value === null || Arr" - } - ] - }, - { - "path": "package/dist/node_modules/@diffexai/diffex-ai/dist/auth/oauth/radius.js", - "sha256": "1a118a3c2a8683efb83e1beffce07dbdaec376a8689b2f0c437605f2b1637ee0", - "matches": [ - { - "identity": "pi-gateway", - "context": "SER = \"browser\";\nconst LOGIN_METHOD_DEVICE_CODE = \"device-code\";\nconst OAUTH_CLIENT_ID = \"pi-gateway\";\nconst OAUTH_SCOPE = \"gateway offline_access\";\nconst OAUTH_DEVICE_CODE_GRANT_TYPE = \"urn:ietf:para" - } - ] - }, - { - "path": "package/dist/core/remote-catalog-provider.js", - "sha256": "6a02ac6407b173cd21677588e5a7fff20757a5c010316b7d2a751694c107603d", - "matches": [ - { - "identity": "pi.dev", - "context": "WithRetry } from \"../utils/management-http.js\";\nconst DEFAULT_CATALOG_BASE_URL = \"https://pi.dev\";\nexport const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;\nfunction mergeModels(baseli" - }, - { - "identity": "pi.dev", - "context": "alGeneratedAt)) {\n return [];\n }\n return entry.models;\n}\n/** Add a persisted pi.dev catalog overlay to a static built-in provider. */\nexport function withRemoteCatalog(provider, catal" - } - ] - }, - { - "path": "package/dist/node_modules/@diffexai/diffex-ai/dist/auth/oauth/xai.js", - "sha256": "958decb827df0ae79a981eb30aee116369c40654289c9f76f666bc0200d20494", - "matches": [ - { - "identity": "pi", - "context": "ODE_URL, {\n client_id: XAI_CLIENT_ID,\n scope: XAI_SCOPE,\n referrer: \"pi\",\n }, signal);\n if (!response.ok) {\n throw requestFailure(\"device authorization\", resp" - } - ] - }, - { - "path": "package/dist/node_modules/@diffexai/diffex-ai/dist/providers/radius-config.d.ts", - "sha256": "a01643a058921789e7ca13101080b7ce913e14b4aa3325de8c9e47ee060c1728", - "matches": [ - { - "identity": "radius.pi.dev", - "context": "kingLevelMap } from \"../types.ts\";\nexport declare const DEFAULT_RADIUS_GATEWAY = \"https://radius.pi.dev\";\nexport type RadiusGatewayModel = {\n id: string;\n name: string;\n reasoning: boolean;\n " - } - ] - }, - { - "path": "package/dist/core/remote-catalog-provider.d.ts", - "sha256": "881b87302b710b12ec77784b315edaa6371e53e117cef8a221aaf9b56bd96f10", - "matches": [ - { - "identity": "pi.dev", - "context": "ai\";\nexport declare const REMOTE_CATALOG_REFRESH_INTERVAL_MS: number;\n/** Add a persisted pi.dev catalog overlay to a static built-in provider. */\nexport declare function withRemoteCatalog(provide" - } - ] - } - ] - }, - "staging": { - "storageSourceCommit": "c5dc431bb94fe1bd572be18a8d1403176e77ef13", - "command": "node scripts/verify-release-publication.mjs --staging --public-api", - "environment": "diffex-release-staging", - "bucket": "diffex-artifacts-staging", - "versions": [ - "0.1789336470229.0", - "0.1789336470229.1" - ], - "exitCode": 0, - "cleanup": "Both version records, latest pointer, and verification lock confirmed absent.", - "latest": { - "status": 404, - "body": { - "error": "no_published_release" - } - }, - "readiness": { - "status": 200, - "body": { - "status": "ready", - "release": "missing" - } - }, - "sourceClientReadonly": { - "code": 1, - "events": [ - { - "type": "request", - "url": "https://api.diffex.ai/api/latest-version", - "method": "GET", - "userAgent": "diffex/0.2.1 (darwin; node/v22.19.0; arm64)", - "redirect": "manual" - }, - { - "type": "staging-connect", - "host": "diffex-public-api-staging.yibai14651.workers.dev", - "port": 443 - }, - { - "type": "staging-response", - "url": "https://diffex-public-api-staging.yibai14651.workers.dev/api/latest-version", - "status": 404, - "body": "{\"error\":\"no_published_release\"}", - "cacheControl": "no-store", - "contentType": "application/json; charset=utf-8" - } - ], - "output": "Error: No published Diffex release is available yet.\n" - }, - "fullCorrectedClientMatrix": "Pending main-only environment policy; no branch access was granted." - }, - "limitations": [ - "No real provider login or inference environment is configured; prompt/reply and account-menu results use controlled fixtures.", - "Full corrected staging source-client matrix needs explicit temporary staging branch access or execution after merge.", - "Source canaries intercept npm installation; installed lifecycle evidence uses real npm and Bun with controlled discovery and canonical scoped registry.", - "The local predecessor changes only isolated installed version metadata; no public-to-public upgrade is claimed.", - "893 default-suite tests are skipped and do not count as accepted external or unsupported-platform behavior.", - "Application network guards and fixture events are not whole-host packet capture or live provider egress acceptance.", - "No standalone binaries, Windows npm acceptance, provider sandboxes, registry images, attestations, hosted deployments, or production metadata writes.", - "Local catalog refresh failed at transport; existing generated data validated, while CI froze a freshly hydrated snapshot." - ] -} diff --git a/docs/release/candidate-rehearsal.md b/docs/release/candidate-rehearsal.md deleted file mode 100644 index 87895c2c3..000000000 --- a/docs/release/candidate-rehearsal.md +++ /dev/null @@ -1,104 +0,0 @@ -# Candidate release rehearsal - -This is historical evidence for the pinned revision below. -Its Radius-containing artifacts are superseded and must not be reused; see [Radius removal](radius-removal.md). - -The rehearsal validates the approved npm-only distribution without publishing a release. -The current public inventory is `@diffexai/diffex`, with internal runtime modules bundled inside its tarball. -The older eleven-package and standalone acceptance requirements are superseded by the owner-approved [distribution policy](distribution-policy.json). -Standalone binaries, Windows npm acceptance, additional package managers, and hosted deployment remain deferred. -Real provider login and inference remain unavailable under the environment limits recorded in [artifact acceptance](artifact-acceptance.md). -Mock-provider prompt/reply and login-menu checks do not establish live provider acceptance. - -## Candidate identity and retained evidence - -The tested CLI source is `af5e64d09f95f5db28883421599769dbdf8bc796` and the coordinated cloud source is `4a87c4492804f68fea380debc43cafa86d0d1fa5`. -The subsequent workflow-only commit `16f94e40a8d6e73414c226e216891da62eed8332` restricts production credential checks to `main`; it changes no application, packaging, or fixture code. -Documentation commits retain these identities rather than relabeling previously tested artifacts. -The rehearsal version is `0.2.1`; these local bytes must never replace an existing npm version. -A production release requires its own reviewed version, immutable candidate, and publication acceptance. - -| Evidence | Result and identity | -| --- | --- | -| Native package matrix | [Artifact Acceptance](https://github.com/diffexai/diffex/actions/runs/34784995881), with the same tarball on Linux x64/arm64 and Darwin x64/arm64. | -| Runtime process and image layers | [Runtime Image](https://github.com/diffexai/diffex/actions/runs/34784999538), Linux amd64, Node 24.16.0, external networking disabled. | -| Staging storage and Worker recovery | [Verify Release Publication](https://github.com/diffexai/diffex/actions/runs/34785182715), using the unchanged publisher and Worker contract from main. | -| Reproduced source-client failure | [Initial staging canary](https://github.com/diffexai/diffex/actions/runs/34784741853), with fixture cleanup verified after failure. | -| Branch environment rejection | [Blocked branch run](https://github.com/diffexai/diffex/actions/runs/34784991684), rejected before jobs could access either environment. | - -The [machine-readable evidence](candidate-rehearsal.json) binds tarball and source-archive checksums, runtime image identity, native reports, local commands, identity review, and cleanup. -Private Actions artifacts retain the original commands, exit codes, terminal transcripts, smoke events, and manifests. -Native acceptance reports have ninety-day retention; candidate tarballs, frozen source, and runtime archives have fourteen-day retention. -Retain the original private bytes before that expiry when longer recovery evidence is required. -The local evidence bundle is `/tmp/diffex-candidate-rehearsal`; it includes the downloaded artifacts and complete local gate output. -No source archive, internal report, or runtime archive was uploaded to a public distribution surface. - -## Installed package and runtime boundaries - -Every advertised native target installs the reviewed CLI through npm 11.16.0 and Bun 1.3.11 with Node 22.19.0. -The local Darwin arm64 run additionally covers npm 10.9.3 and Bun 1.3.11 on macOS 26.1. -Both managers use fresh isolated consumers outside the repository and disable lifecycle scripts. -Diffex-scoped registry access cannot silently substitute an older public package for the local tarball. -The harness verifies installed runtime hashes, package exports, native clipboard loading, image WASM, HTML export resources, and controlled `fd`/`rg` helpers. -Version, help, models, a printed prompt/reply, a terminal prompt/reply, the supported account menu, and clean terminal exit are checked through the installed executable. - -Real npm and Bun subprocesses install, upgrade, and uninstall the package. -The upgrade predecessor changes only isolated installed version metadata to `0.0.0`, with discovery and the canonical scoped registry supplied by controlled fixtures. -Malformed and foreign metadata, prereleases, redirects, outages, missing releases, and corrupted npm downloads are rejected under force. -An interrupted npm download is repaired using the exact pinned candidate, including recovery of a stale installation backup when required. -Uninstall removes the executable while retaining global and project user data. -The runtime fixture starts the actual hosted server, rejects anonymous, wrong-credential, and foreign-session connections, exchanges a mock prompt/reply, reports mock usage, restarts with persisted session state, and shuts down. -The runtime image is local acceptance evidence, not a GHCR release digest, attested registry artifact, Daytona snapshot, or deployed rollback baseline. - -## Promotion and recovery boundaries - -The isolated `./test.sh` run passes 248 release/script tests and 4,948 Vitest tests. -Its 893 skipped tests remain explicitly unaccepted; configured-provider and unsupported-platform cases are not converted into passing evidence. -Command-level publication fixtures exercise missing packages, unavailable tarballs, integrity and byte mismatches, partial publication, conflicting immutable identity, actual pointer-write failure, retries, non-regression, and repeated completion. -Source-selection and artifact-recovery tests reject dirty or divergent sources, unapproved channels, missing retained evidence, and tampered artifacts before remote writes. -Partial publication retains a deliberately synthetic multi-package inventory to exercise generic publisher recovery even though the public release now contains one package. -Those controlled npm/Git/storage commands are not real registry publication. - -The staging run invokes `node scripts/verify-release-publication.mjs --staging --public-api` through the existing `diffex-release-staging` environment. -It exercises real conditional R2 writes and anonymous Worker reads for creation, repeated completion, invalid records, unavailable prerequisites, newer advancement, older non-regression, and recovery from a retained version record with a missing pointer. -The fixture versions are `0.1789336470229.0` and `0.1789336470229.1`. -Cleanup verifies both version keys, `releases/v1/latest.json`, and `verification/release-publication.lock` absent. -The final anonymous latest-version response is HTTP 404 `no_published_release`; readiness is HTTP 200 with `release: "missing"`. -The production credential check is read-only and confirms bucket isolation. - -## Source-client startup repair and remaining gate - -The initial full staging canary failed before its first HTTP request because a clean checkout lacks `packages/ai/src/providers/data/.manifest.json`. -A clean source archive reproduced `ERR_MODULE_NOT_FOUND` with exit code 1 and an empty request log. -Restoring the validated model snapshot made the same source executable report `0.2.1` and reach staging normally. -The workflow now hydrates and validates model data, checks offline source startup before acquiring the staging fixture lock, and pins Node 22.19.0. -Its connection assertion includes captured startup output so an expected negative update exit cannot hide an import failure. -The source CLI regression suite passes all 32 cases after this repair. -An anonymous clean-source check observes the staging no-release response with one request, no installation command, and verified local cleanup. - -Both GitHub release environments currently allow only `main`. -The corrected full source-client matrix on the branch requires explicit temporary staging branch access or execution after merge. -That full credentialed matrix remains pending and is not represented as passed. -Production environment protections remain unchanged, and branch workflows skip production credential access. -When access is authorized, run the exact branch revision, verify all client cases and fixture cleanup, then remove the temporary staging branch rule. -The source-client canary always substitutes npm responses and intercepts installation; real installed-package acceptance is established separately above. - -## Network destinations and privacy - -The source-client guard permits only `diffex-public-api-staging.yibai14651.workers.dev:443` after remapping the canonical latest-version URL. -The retained read-only client result records anonymous GET, redirects disabled, JSON, `Cache-Control: no-store`, and the actual staging response. -Storage rehearsal contacts the configured Cloudflare R2 account endpoint using staging-scoped credentials and the `diffex-artifacts-staging` bucket. -Installed terminal fixtures record the logical `https://api.diffex.ai/api/latest-version` request but answer locally and reject unexpected socket attempts. -Lifecycle tests use loopback discovery/registry fixtures, with third-party dependency downloads through `registry.npmjs.org`. -These application-level guards and event logs are not a whole-host packet capture or proof of live provider egress. -Catalog preparation contacts its approved catalog inputs; the local refresh encountered a transport failure, so the existing validated local snapshot was used for source gates while the native build retained its freshly hydrated snapshot. - -The historical source identity gate accepted seventeen reviewed occurrences across 915 files. -Inspection of that historical tarball found sixteen identity matches in nine files, then approved as Radius, catalog, OAuth, and provider-attribution entries. -Radius approval has since been revoked for every environment. -Catalogs, ChatGPT registration, and application attribution have also migrated to the current Diffex defaults described in [the residual inventory](../../identity-residual-allowlist.md). -The JSON evidence preserves its original URLs, headers, and review labels; it does not define current service permissions. -The final payload gate verifies required notices, runtime-file hashes, the documentation allowlist, and absence of prohibited source and embedded maps. -Image inspection accepts 1,414 project files across twelve runtime workspaces and thirty approved documentation assets. -All isolated installation prefixes, caches, homes, mock sessions, fixture servers, and runtime containers are removed by their harnesses. -No provider sandbox, hosted deployment, production metadata record, registry package, or release tag is created by this rehearsal. diff --git a/docs/release/cli-only-candidate-evidence.json b/docs/release/cli-only-candidate-evidence.json deleted file mode 100644 index e019438dd..000000000 --- a/docs/release/cli-only-candidate-evidence.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "sourceCommit": "f2a661684af35fe6e9a3209c6f9720144e1fd423", - "version": "0.2.1", - "packages": [ - { - "name": "@diffexai/diffex", - "version": "0.2.1", - "filename": "diffexai-diffex-0.2.1.tgz", - "size": 3530237, - "sha256": "64c161d63ef416f6bf116a6b35953225dc49c236ccba0ef4b88c2ac8ff4452b0", - "integrity": "sha512-ZBrTmPGnJy/C7u8DhZA/owIDELJGzPf2ujbRtZCPyvSfQEAtXGZk2C0PQ1/ctbyFKMXXpkXPlO7Asm5E/s//5g==" - } - ], - "localAcceptance": { - "platform": "darwin", - "arch": "arm64", - "node": "v22.19.0", - "status": "passed", - "cleanup": true, - "packages": [ - { - "manager": "npm", - "managerVersion": "10.9.3", - "name": "@diffexai/diffex", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "operation": "passed", - "localDependencies": [ - "@diffexai/diffex" - ] - }, - { - "manager": "bun", - "managerVersion": "1.3.11", - "name": "@diffexai/diffex", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "operation": "passed", - "localDependencies": [ - "@diffexai/diffex" - ] - } - ], - "processes": [ - { - "manager": "npm", - "server": "Excluded from CLI distribution; hosted runtime remains private", - "terminal": "passed", - "helpers": { - "fd": "fd 10.4.2", - "rg": "ripgrep 15.2.0 (rev e89fff89ac)\n\nfeatures:+pcre2\nsimd(compile):+NEON\nsimd(runtime):+NEON\n\nPCRE2 10.45 is available (JIT is available)" - } - }, - { - "manager": "bun", - "server": "Excluded from CLI distribution; hosted runtime remains private", - "terminal": "passed", - "helpers": { - "fd": "fd 10.4.2", - "rg": "ripgrep 15.2.0 (rev e89fff89ac)\n\nfeatures:+pcre2\nsimd(compile):+NEON\nsimd(runtime):+NEON\n\nPCRE2 10.45 is available (JIT is available)" - } - } - ] - }, - "nativeWorkflow": "https://github.com/diffexai/diffex/actions/runs/34765173973", - "nativeStatus": "Blocked before execution by GitHub billing or spending limit", - "published": false -} diff --git a/docs/release/cli-publication-evidence.json b/docs/release/cli-publication-evidence.json deleted file mode 100644 index b1608820d..000000000 --- a/docs/release/cli-publication-evidence.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "verifiedAt": "2026-09-13T15:44:11.177Z", - "sourceCommit": "f2a661684af35fe6e9a3209c6f9720144e1fd423", - "artifact": { - "name": "@diffexai/diffex", - "version": "0.2.1", - "filename": "diffexai-diffex-0.2.1.tgz", - "size": 3530237, - "sha256": "64c161d63ef416f6bf116a6b35953225dc49c236ccba0ef4b88c2ac8ff4452b0", - "integrity": "sha512-ZBrTmPGnJy/C7u8DhZA/owIDELJGzPf2ujbRtZCPyvSfQEAtXGZk2C0PQ1/ctbyFKMXXpkXPlO7Asm5E/s//5g==" - }, - "published": { - "name": "@diffexai/diffex", - "version": "0.2.1", - "tarball": "https://registry.npmjs.org/@diffexai/diffex/-/diffex-0.2.1.tgz", - "integrity": "sha512-ZBrTmPGnJy/C7u8DhZA/owIDELJGzPf2ujbRtZCPyvSfQEAtXGZk2C0PQ1/ctbyFKMXXpkXPlO7Asm5E/s//5g==" - }, - "distTags": { - "bootstrap": "0.2.1", - "latest": "0.2.1" - }, - "retired": [ - { - "name": "@diffexai/diffex-agent-core", - "version": "0.2.0", - "absent": true, - "packageStatus": 404, - "versionStatus": 404, - "tarballStatus": 404, - "remainingVersions": [] - }, - { - "name": "@diffexai/diffex-ai", - "version": "0.2.0", - "absent": true, - "packageStatus": 404, - "versionStatus": 404, - "tarballStatus": 404, - "remainingVersions": [] - }, - { - "name": "@diffexai/diffex-client", - "version": "0.2.0", - "absent": true, - "packageStatus": 404, - "versionStatus": 404, - "tarballStatus": 404, - "remainingVersions": [] - }, - { - "name": "@diffexai/diffex", - "version": "0.2.0", - "absent": true, - "packageStatus": 200, - "versionStatus": 404, - "tarballStatus": 404, - "remainingVersions": [ - "0.2.1" - ] - }, - { - "name": "@diffexai/diffex-coding-agent-server", - "version": "0.2.0", - "absent": true, - "packageStatus": 404, - "versionStatus": 404, - "tarballStatus": 404, - "remainingVersions": [] - }, - { - "name": "@diffexai/diffex-harness-state", - "version": "0.2.0", - "absent": true, - "packageStatus": 404, - "versionStatus": 404, - "tarballStatus": 404, - "remainingVersions": [] - }, - { - "name": "@diffexai/diffex-protocol", - "version": "0.2.0", - "absent": true, - "packageStatus": 404, - "versionStatus": 404, - "tarballStatus": 404, - "remainingVersions": [] - }, - { - "name": "@diffexai/diffex-server", - "version": "0.2.0", - "absent": true, - "packageStatus": 404, - "versionStatus": 404, - "tarballStatus": 404, - "remainingVersions": [] - }, - { - "name": "@diffexai/diffex-session-backend-sqlite-node", - "version": "0.2.0", - "absent": true, - "packageStatus": 404, - "versionStatus": 404, - "tarballStatus": 404, - "remainingVersions": [] - }, - { - "name": "@diffexai/diffex-telemetry", - "version": "0.2.0", - "absent": true, - "packageStatus": 404, - "versionStatus": 404, - "tarballStatus": 404, - "remainingVersions": [] - }, - { - "name": "@diffexai/diffex-tui", - "version": "0.2.0", - "absent": true, - "packageStatus": 404, - "versionStatus": 404, - "tarballStatus": 404, - "remainingVersions": [] - } - ], - "trust": { - "id": "a26ffd56-ad7f-4e22-bffa-9086e41a38fb", - "type": "github", - "file": "build-binaries.yml", - "repository": "diffexai/diffex", - "environment": "npm-publish", - "permissions": [ - "createPackage", - "createStagedPackage" - ] - }, - "traditionalTokenPublishing": { - "command": "npm access set mfa=publish @diffexai/diffex --registry=https://registry.npmjs.org", - "exitCode": 0, - "acceptedAt": "2026-09-13T15:44:07.515Z", - "publish_requires_tfa": true, - "automation_token_overrides_tfa": false, - "verification": "Authenticated npm access command accepted; flags follow the pinned npm 11.16.0 setMfa implementation" - }, - "ownerApprovedSkippingBlockedGitHubValidation": true, - "publicRegistryInstall": { - "startedAt": "2026-09-13T15:43:37.001Z", - "package": "@diffexai/diffex@0.2.1", - "registry": "https://registry.npmjs.org", - "authenticated": false, - "platform": "darwin", - "arch": "arm64", - "managers": [ - { - "manager": "npm", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "installedFileHashes": "passed", - "runtimeNativeWasmAndHtmlExport": "passed", - "help": "passed" - }, - { - "manager": "bun", - "version": "0.2.1", - "entrypoints": [ - "@diffexai/diffex", - "@diffexai/diffex/rpc-entry", - "@diffexai/diffex/client" - ], - "installedFileHashes": "passed", - "runtimeNativeWasmAndHtmlExport": "passed", - "help": "passed" - } - ], - "status": "passed", - "cleanup": true, - "finishedAt": "2026-09-13T15:43:48.479Z" - }, - "issues": [ - "npm rejected removal of latest tag" - ] -} diff --git a/docs/release/client-canaries.md b/docs/release/client-canaries.md deleted file mode 100644 index b0f0f7266..000000000 --- a/docs/release/client-canaries.md +++ /dev/null @@ -1,112 +0,0 @@ -# Staging release client acceptance - -Verified on 2026-09-10 with Node 22.19.0 and AWS CLI 1.46.1. -All 21 source CLI canaries passed against real R2 and the deployed staging Worker. -The CLI source is based on `9b6f2dcab` from PR #204, with the reusable canary harness added on `feat/staging-version-canaries`. -The cloud source and regression checks use `01c1205` from Diffex Cloud PR #145. -The [cloud staging record](https://github.com/diffexai/diffex-cloud/blob/feat/serve-verified-releases/docs/public-api-staging.md) documents the existing deployment; this run made no Worker deployment. - -## Executed command and boundary - -From the CLI repository root, with the dedicated staging credentials supplied through the environment: - -```sh -node scripts/verify-release-publication.mjs --staging --client-canaries -``` - -The real publisher verifies controlled npm responses for all 11 public workspace packages and writes only the private `diffex-artifacts-staging` bucket in account `6b24afa849f8254ab5e6e2d1956239cc`. -It created synthetic versions `0.1789086098745.0` and `0.1789086098745.1`; these are test fixtures, not published npm releases. -Every client requested `GET https://api.diffex.ai/api/latest-version` with manual redirect handling. -Only the test preload remapped that exact URL to `https://diffex-public-api-staging.yibai14651.workers.dev/api/latest-version`. -Actual sockets were restricted to that hostname on port 443. -Requests were anonymous, with no cookies or R2 credentials supplied to the clients. -Every response had a JSON content type and `Cache-Control: no-store`. - -Manual commands ran the source CLI with `update --self`, adding `--force` where listed. -Separate temporary manifests modeled installed versions below, equal to, and above the staging record. -Interactive clients ran with `--no-session --no-extensions --no-skills` in a real 100x30 PTY, waited for the staging response and welcome screen, and exited through `/exit` without submitting a model prompt. -The harness removed every temporary home, project, credential directory, event record, and simulated installation prefix. - -## Observed behavior - -| Canary | Version requests | HTTP | Exit | Install attempts | -| --- | --- | --- | --- | --- | -| missing release | 1 | 404 | 1 | 0 | -| forced missing release | 1 | 404 | 1 | 0 | -| interactive missing release | 1 | 404 | 0 | 0 | -| newer release | 1 | 200 | 0 | 1 | -| equal release | 1 | 200 | 0 | 0 | -| older release | 1 | 200 | 0 | 0 | -| forced equal release | 1 | 200 | 0 | 1 | -| forced older release | 1 | 200 | 0 | 1 | -| manual automatic opt-out | 1 | 200 | 0 | 1 | -| manual offline | 0 | none | 1 | 0 | -| interactive newer release | 1 | 200 | 0 | 0 | -| interactive offline | 0 | none | 0 | 0 | -| interactive automatic opt-out | 0 | none | 0 | 0 | -| forced invalid JSON | 3 | 503 | 1 | 0 | -| interactive unavailable | 1 | 503 | 0 | 0 | -| forced wrong schema | 3 | 503 | 1 | 0 | -| forced prerelease semver | 3 | 503 | 1 | 0 | -| forced missing canonical package | 3 | 503 | 1 | 0 | -| forced duplicate canonical package | 3 | 503 | 1 | 0 | -| forced package version mismatch | 3 | 503 | 1 | 0 | -| forced foreign package | 3 | 503 | 1 | 0 | - -Missing releases printed `No published Diffex release is available yet.` and exited 1 after one request. -Invalid stored records produced HTTP 503 and `Diffex update service is unavailable. Try again later.`, with three manual requests and no package commands even under force. -Interactive failures stayed quiet and made at most one version request. -Equal and older releases printed `already up to date` unless forced. -Offline mode suppressed all version requests, while automatic opt-out still allowed manual update. - -All four intercepted install attempts matched: - -```sh -npm --prefix install -g --ignore-scripts --min-release-age=0 @diffexai/diffex@0.1789086098745.0 -``` - -No package-manager process executed, no uninstall was attempted, and no installed software changed. -The preload intercepted the separate `https://pi.dev/api/report-install` call during non-offline startup and returned a controlled failure without forwarding it. -There was no Pi version-check traffic or any Pi socket connection. -At the time of this historical run, install telemetry, model catalogs, and Radius remained separate migration work. -Install telemetry has since been [removed](install-telemetry-removal.md), and catalogs now use the [Diffex authority](../model-catalog.md). -Radius is now removed and prohibited in all environments; see [Radius removal](radius-removal.md). - -## Regression and artifact checks - -The focused checker, source CLI, package-command, and consumer-contract suites passed all 155 tests before the harness extraction. -The changed source CLI test file passed all 32 cases after extraction, and the final full isolated `./test.sh` run passed. -That full run includes the publisher and shared contract corpus, all changed tests, and default tests with live E2E disabled. -The checker tests cover invalid HTTP JSON, unsupported semver, exact identity validation, timeouts, HTTP errors, all supported redirect statuses without forwarding, retry limits, offline mode, and automatic opt-out. -The stored-record corpus covers wrong schemas, missing and duplicate packages, and identity/version mismatches. - -The CLI `npm run check`, workflow validation with actionlint 1.7.12, and Cloud-to-CLI contract document and fixture comparisons passed. -Cloud root `npm run check` passed 546 unit and 23 integration tests and its typecheck, lint, formatting, database, build, and audit checks. -The isolated Worker `npm run check` passed 335 tests, typecheck, lint, formatting, staging deployment dry run, and audit. - -The initial local artifact scan found the historical Pi endpoint in `packages/coding-agent/dist/utils/version-check.js`. -With explicit owner approval, `npm run build` from `packages/coding-agent` refreshed the local artifact without publishing or modifying generated files manually. -The final scan returned no matches: - -```sh -rg -n 'pi\.(dev|codes)/api/latest-version' packages/coding-agent/src packages/coding-agent/dist --glob '!*.map' -``` - -Both `src/utils/version-check.ts` and rebuilt `dist/utils/version-check.js` contain the canonical Diffex URL. -Generated build output remains untracked and is not included in the PR. - -## Cleanup and limits - -The run verified byte-preserving repeated publication, failed npm verification before and after publication, conditional advancement, older-announcement rejection, and interrupted-publication recovery. -Cleanup verified these exact keys absent before releasing the lock: - -- `releases/v1/latest.json` -- `releases/v1/releases/0.1789086098745.0.json` -- `releases/v1/releases/0.1789086098745.1.json` -- `verification/release-publication.lock` - -The final staging latest response was HTTP 404 `no_published_release`, and readiness was HTTP 200 with `release: "missing"`. -No production object, route, DNS record, Worker, Render service, landing application, npm package, or GitHub Release was changed. -The opt-in workflow input becomes dispatchable after the workflow reaches the default branch; this evidence comes from running the checked-in command locally. -These are source CLI canaries with intercepted installation, not acceptance of an installed published client or public binary distribution. -Production activation and real installation remain separately authorized release work. diff --git a/docs/release/contract.md b/docs/release/contract.md deleted file mode 100644 index d7d2eac77..000000000 --- a/docs/release/contract.md +++ /dev/null @@ -1,107 +0,0 @@ -# Stable npm release contract - -This contract governs `releases/v1/latest.json`, `releases/v1/releases/.json`, and unauthenticated `GET https://api.diffex.ai/api/latest-version`. -The CLI monorepo publishes records only after downloading every public workspace package at its exact version and comparing the tarball bytes with the retained approved build manifest. -The public API Worker validates the stored record and projects the public response without querying npm per request. -The marker means npm-verified stable release availability at publication time. -The approved distribution policy currently enables npm and defers standalone binaries and other installation channels. -The publisher rejects additional enabled channels until their required public asset verification is implemented. -This schema remains an npm contract and does not describe binary assets, signatures, install locks, or a cross-channel release manifest. -The private build manifest binds the release commit, source tree, lockfile, disclosure policy, frozen model data, private source archive, and exact npm tarball sizes, SHA-256 digests, and SHA-512 integrity values. -Registry verification downloads each canonical npm tarball anonymously, rejects redirects, and recomputes both digests before accepting an existing npm version or advancing release storage. -The stronger publisher prerequisites add no stored or HTTP fields and require no installed-client update. - -## Stored record - -Records are JSON objects with exactly these required fields. -Unknown fields are rejected at the root and within packages; schema changes require coordinated publisher and consumer updates. -Records have no top-level `packageName`, `channel`, or `note`. - -| Field | Requirement | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `schemaVersion` | JSON number `1`. | -| `version` | Canonical stable `major.minor.patch` string, each component `0` or a nonzero decimal digit followed by decimal digits. | -| `sourceCommit` | Full Git SHA-1 as exactly 40 lowercase hexadecimal characters. | -| `publishedAt` | Real UTC calendar timestamp in the publisher's `YYYY-MM-DDTHH:mm:ss.sssZ` format, round-tripping through `Date.toISOString()`. | -| `packages` | Nonempty array of package objects with unique names, including exactly one `@diffexai/diffex`. | - -Versions exclude prereleases, build metadata, leading `v`, leading zeroes, surrounding whitespace, and trailing newlines. -Components are decimal integers without a JavaScript safe-integer ceiling; the publisher compares them with `BigInt`. -Consumers must reject unsupported numeric ranges rather than rounding them or constructing a different installation target. - -Each package has exactly `name`, `version`, `tarball`, and `integrity`. -Names use lowercase npm-style names, optionally scoped, matching `(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*` in full, with at most 214 characters. -Every package version equals the record version, including the canonical CLI package. -The Worker does not hardcode the workspace package inventory; verifying completeness against the source checkout belongs to release CI. -Package order has no meaning. - -Tarballs are canonical absolute HTTPS URLs that round-trip through `URL.href`, without embedded username, password, or fragment. -Integrity is one `sha256`, `sha384`, or `sha512` SRI digest with canonical padded base64 encoding and the corresponding 32, 48, or 64 decoded bytes. -Empty values, malformed encodings, unsupported algorithms, and multiple digests are rejected. -Tarball URLs, integrity, commit, timestamp, and workspace inventory stay out of the public latest-version response. - -The publisher validates new records before writing either key and validates existing records before using them for repeat publication or latest-pointer comparisons. -Existing malformed records fail closed and are not treated as missing. -Publication still requires npm verification, immutable versioned records, and conditional latest-pointer writes that cannot regress the version. - -## HTTP behavior - -| Condition | Status | JSON body | Additional header | -| ------------------------------------------------------------------------ | ------ | ------------------------------------------------------------------- | ----------------- | -| Valid stored release | `200` | `{"version":"","packageName":"@diffexai/diffex"}` | None | -| Latest object absent | `404` | `{"error":"no_published_release"}` | None | -| Invalid JSON, invalid record, read/body failure, or inaccessible storage | `503` | `{"error":"release_unavailable"}` | None | -| Method other than `GET` | `405` | `{"error":"method_not_allowed"}` | `Allow: GET` | - -All responses use `Content-Type: application/json; charset=utf-8` and `Cache-Control: no-store`. -The HTTP runtime suppresses the body for `HEAD`, while preserving the `405` status and headers. -Method rejection takes precedence over release availability and should happen before storage access. -Only absence of the fixed latest object means no published release; JSON `null`, denied access, malformed content, and a missing binding mean unavailable. -Internal exception messages must never enter responses. -The endpoint has no caller-dependent version filtering, authentication, cookies, or credential selection and ignores query parameters. -Equal, older, and newer callers receive the same verified release. -There is no npm-tag, Pi, binary URL, or other fallback authority. -Unsupported paths, including trailing-slash and suffix variants, remain `404 {"error":"not_found"}` at the Worker routing boundary. - -## Ownership and conformance - -The cloud implementation lives in `services/public-api/src/release-contract.ts` and `latest-version-response.ts`. -The CLI counterpart lives in `packages/coding-agent/src/utils/release-contract.ts` and is used by the publisher. -The cloud fixture corpus is `services/public-api/fixtures/release-contract.json`; its CLI copy is `scripts/fixtures/release-contract.json`. -This document is mirrored as cloud `docs/release-contract.md` and CLI `docs/release/contract.md`. -The fixtures are synthetic examples, never proof of an actual published version. - -When either side changes, update both copies of this document and the fixture corpus, run both repositories' contract tests and required quality gates, and include companion PR links. -From the cloud root, compare the two checkouts with: - -```sh -node scripts/check-release-contract.mjs /path/to/diffex-new -``` - -Run cloud contract tests from `services/public-api`: - -```sh -npm run test -- tests/unit/release-contract.test.ts -npm run check -``` - -Run publisher tests from the CLI root and consumer fixture tests from `packages/coding-agent`: - -```sh -node --test scripts/release-contract.test.mjs scripts/publish-release-announcement.test.mjs -node ../../node_modules/vitest/dist/cli.js --run test/release-contract.test.ts -``` - -The contract corpus covers required fields and types, stable version exclusions, package identity and uniqueness, lockstep versions, timestamp, commit, URL, integrity, unknown fields, public projection, and all HTTP outcomes. -Command-level publisher tests validate actual emitted records against the corpus rules with controlled npm responses and intercepted storage operations. -All temporary publisher fixtures are removed after testing, and these checks need no staging or production credentials. - -## Integration status - -The public Worker serves the fixed R2 latest record, and the CLI consumes the exact canonical version endpoint with a pinned npm installation target. -The cloud production and staging operations records retain their deployment and route evidence. -This publisher hardening introduces no Worker deployment or live release publication. -Command-level publication regressions use controlled npm bytes and intercepted storage operations. -The unchanged cloud contract fixtures and Worker tests verify consumer compatibility. -Live publication against actual approved packages remains gated by the separate release authorization and packaging prerequisites. -The landing application and authenticated Render `/v1` contracts require no change. diff --git a/docs/release/distribution-review.md b/docs/release/distribution-review.md deleted file mode 100644 index 7d4e2f44f..000000000 --- a/docs/release/distribution-review.md +++ /dev/null @@ -1,139 +0,0 @@ -# Approved npm distribution policy - -The owner approved npm as the host for every public artifact in this release on 2026-09-12. -Standalone archives, Python, all secondary installation channels, and hosted runtime publication are deferred. -The implementation starts from CLI PR #208 at `ae811d134dd63b0a688aea10ea0368c0935e7e48`. -The canonical source repository remains private, and the hosted image identity remains `ghcr.io/diffexai/diffex-runtime`. -This work does not authorize npm bootstrap or production publication. - -## Distributions and source disclosure - -[distribution-policy.json](distribution-policy.json) records the approved decisions. -On 2026-09-13 the owner revised the public inventory to only `@diffexai/diffex` and approved a bundled `0.2.1` replacement with retirement of `0.2.0`. -The manifest generator now rejects every additional public package. -It rejects private workspace names, the generated private installer package as an npm publication, obsolete `@diffexai/diffex-coding-agent`, incomplete inventories, and mixed versions. -The source commit is a full identity string and is not copied into another repository's Git history. - -Readable JavaScript and declarations ship in all public packages. -The CLI also ships runtime JavaScript and declarations from `dist/vendor/evolution` and `dist/vendor/harness-trace`; their private workspace flags do not make those delivered files private. -The approved CLI-only replacement also includes the agent, AI, client, harness-state, protocol, telemetry, and TUI runtime workspaces under `dist/node_modules/@diffexai`. -Their previously reviewed runtime assets and complete notices are preserved, while publication and installation locks exclude separate Diffex registry dependencies. -TypeScript examples and native source/build files are excluded. -Runtime JSON, SQLite migration SQL, HTML/CSS export templates, documentation with inline examples, and Diffex-owned TUI native prebuilds remain included. -The TUI helpers call system APIs; their source remains in the private build input and their project notices remain in the package. - -The owning preparation tool checks that each emitted runtime path has an approved source input and rejects unexpected build output. -It records the exact runtime paths and SHA-256 values in each package's `distribution-files.json`. -The final tarball checker additionally records every delivered path and hash, including documentation, notices, and binary resources, in private publication evidence. -Source maps are moved to `.artifacts/private-debug///` and mapping directives are removed from generated JavaScript and declarations. -CI retains debug maps and final payload inventories only as artifacts of the private source repository. -Original TypeScript, inline maps, and embedded `sourcesContent` are rejected in public payloads. -Delivered code remains inspectable; repository privacy, minification, obfuscation, and compilation do not guarantee confidentiality or prevent reverse engineering. - -## License and notice inventory - -The CLI and all bundled workspace manifests declare `MIT`. -The two vendored Diffex workspaces also declare `MIT`. -Each public package receives the complete root `LICENSE`, preserving the MIT terms, Luntraa, Inc. ownership, and the complete upstream MIT license and copyright notice for Pi-derived portions. -The package declaration does not replace the MIT terms of those portions or other third-party terms. -The generated `NOTICE` describes that distinction and the Diffex modifications. - -Each package's `distribution-components.json` identifies project portions, bundled components, notice filenames and hashes, and separately resolved package-manager dependencies. -The CLI's bundled HTML exporter includes marked 18.0.5 under MIT and highlight.js 11.9.0 under BSD-3-Clause, with their complete notices in `THIRD-PARTY-NOTICES.txt`. -The latter differs from the separately installed highlight.js 10.7.3 dependency and is inventoried independently. -Exact upstream license revisions are retained in `legal/upstream/sources.json`. -External npm dependencies are resolved separately by the package manager; they are not incorrectly counted as code bundled in a Diffex npm tarball. - -The standalone review found Bun's documented statically linked LGPL components and relinking requirements. -The owner therefore deferred standalone publication rather than approving an incomplete runtime notice/source/object set. -The [Bun 1.3.14 notice](../../legal/upstream/bun-1.3.14.LICENSE) records that finding. -The standalone build entrypoints are deliberately blocked, the required binary asset list is empty, and the runtime image workflow remains paused. -Enabling either distribution requires a new complete native dependency, runtime layer, license, and redistribution-material review. - -## Public downloads and verification - -The public host is `https://registry.npmjs.org`, under the Diffex-controlled npm package namespace. -Versioned tarball URLs are `https://registry.npmjs.org//-/-.tgz`. -For canonical CLI 0.2.0, the URL is `https://registry.npmjs.org/@diffexai/diffex/-/diffex-0.2.0.tgz`. -No private source-repository credentials are required after the approved version is published. -Npm versions are immutable; published bytes must never be replaced. - -The CLI tarball includes `release/distribution-manifest.json`, `release/install-package.json`, and `release/install-package-lock.json`. -The installer metadata remains private-marked to prevent separate npm publication, but its approved contents are intentionally public inside the CLI tarball. -Each public package includes `distribution-files.json` with its runtime file checksums and source commit. -Npm registry `dist.integrity` verifies the complete tarball's bytes, including embedded verification files. -The workflow publishes only checked tarballs; it no longer creates GitHub releases or requires an asset-repository token. -Npm trust remains bound to private `diffexai/diffex`, workflow `build-binaries.yml`, environment `npm-publish`. - -The full source archive remains a private build input and is excluded from all public package and publication lists. -Its owning validator requires the root license and model snapshot invariants. -The public cloud release record remains the existing npm publication contract; the richer embedded manifest is not a replacement R2 schema. - -## Historical changelog reset and local acceptance - -The owner explicitly approved the one-time reset after automatic review required direct authorization. -Only the nine paths listed in the release audit were cleared; their previous contents remain at `ae811d134dd63b0a688aea10ea0368c0935e7e48`. -At the owner's request, `./diffex-test.sh` was launched in a dedicated tmux session and `/cl` regenerated all nine files from Diffex change history. -The audit completed structural, cross-package coverage, and changelog-parser checks, with no inherited release sections restored. -Packaging rejects empty or structurally invalid project changelogs. -The regenerated files pass the 94 focused changelog, configuration, and version-check tests, and `release-notes.mjs extract --version Unreleased --tag v0.2.0` extracts their release notes. -Do not repeat the reset during normal releases or replace upstream legal notices with changelog entries. - -An actual baseline CLI tarball reproduced the source disclosure issue through `npm pack --ignore-scripts --json --cache /tmp/diffex-npm-cache --pack-destination /tmp/diffex-distribution-baseline` from `packages/coding-agent`. -It contained TypeScript examples, native C source, source maps, and duplicated vendored build directories. -The new checker rejects that baseline. -Regression fixtures exercise real npm packing, notice inclusion, private debug retention, missing notices, prohibited source, renamed and inline maps, map strings inside native resources, nested archives, unapproved workspaces, and manifest identity errors. -The tests also prove that reset changelogs block packing before registry queries or writes. - -The CLI quality gate, full isolated `./test.sh` suite, and focused changelog/config/version tests passed; the cloud quality gate and shared release contract checker passed. -All eleven build directories passed the approved-input preparation check before final packing. -The runtime checksum inventory also covers the TUI native prebuilds. -Local artifact acceptance completed for source commit `196a385b4c24210522b50a6037355f1ef617545c` after `/cl` regeneration. -Anonymous public download acceptance follows separately authorized npm publication and is not claimed by local fixtures. -This implementation did not publish npm packages or create production release records, deployments, hosted sessions, or provider sandboxes. - -## Recorded local artifact acceptance - -Validation ran on Darwin arm64 with Node 22.19.0, npm 10.9.3, and Bun 1.3.11. -The candidate version is 0.2.0; these artifacts were not published. -The following commands completed successfully: - -```sh -npm run check -./test.sh -node scripts/prepare-candidate-artifacts.mjs '/tmp/diffex npm distribution candidate' -python3 scripts/verify-candidate-artifacts.py '/tmp/diffex npm distribution candidate' -./scripts/create-source-archive.sh --version 0.2.0 --ref 196a385b4c24210522b50a6037355f1ef617545c --out /tmp/diffex-private-source-0.2.0.tar.gz -``` - -All eleven actual tarballs passed final-byte source, notice, component, runtime-hash, and identity inspection. -Both isolated package-manager installations resolved all eleven local tarball identities, and every package entrypoint imported successfully with outbound sockets blocked. -The installed CLI passed help, version, auth help, model listing, fixture inference, interactive prompt/reply, account-menu inspection, and clean exit in both installations. -The fixture intercepted release discovery and inference; it did not authenticate a real account or invoke a real model provider. -Native execution on other operating systems or architectures is not established by this host's results. -The test homes and processes were cleaned up, and the temporary package installation directories were removed after retaining evidence. - -A rebuild from the committed source reproduced the exact runtime and generated metadata bytes in all eleven inspected tarballs. -It retained 1,216 source maps under the matching commit in private debug storage. -The runtime inventories cover 1,269 files, including four project-owned native prebuilds. -The full source archive passed its independent required-file and model-snapshot checks. -Its SHA-256 is `cd734c1ec4606c68f72c50e62c65554f9f3c36ff814b87eef378793bdf685f78`; it remains a private build input. - -The local evidence directory is `.artifacts/distribution-acceptance/196a385b4c24210522b50a6037355f1ef617545c/`. -It retains the checked tarballs, full payload inventories, embedded manifest, command logs, terminal results, package import results, and environment identity. -CI uses private workflow artifacts for its equivalent evidence. -The following SHA-256 values cover the complete local tarballs: - -| Package | SHA-256 | -| --- | --- | -| `@diffexai/diffex-agent-core@0.2.0` | `14cc1d808e2a17e601758fa856f6aafe03a3d1ba7fab6f17aee6367188c957ca` | -| `@diffexai/diffex-ai@0.2.0` | `307209b097f0ca349256de98b44555576c215a7dba9ca6e11a1ff318fb06e8bc` | -| `@diffexai/diffex-client@0.2.0` | `d3ce06054506433edb3c7abec849f4bc9bdb3c83067b3dc08bf0e478557c9216` | -| `@diffexai/diffex@0.2.0` | `97b327ac33c04e0dcabc4732f47ee72d0e98980c4a59cdd836d02dcb00c79260` | -| `@diffexai/diffex-coding-agent-server@0.2.0` | `b7bf6181137f35bc0c119da969ffc26425756161547bc86654a782f2703ba7a5` | -| `@diffexai/diffex-harness-state@0.2.0` | `b8eda020f0fb6449ea6cdac1e359b102318f0871317cef827b27dfafa2e2452f` | -| `@diffexai/diffex-protocol@0.2.0` | `a9cd8f3983982d683f7c2a33c7dba96958234973abaca1533267daee1f8da706` | -| `@diffexai/diffex-server@0.2.0` | `788a04fbeee68260b62da9c0dd2844422069117043d2940582543afad4f462ac` | -| `@diffexai/diffex-session-backend-sqlite-node@0.2.0` | `647b5c93973f46d4be3f791bf1a8d6c5e08fc54309ec73d9a450abe1d27f9c67` | -| `@diffexai/diffex-telemetry@0.2.0` | `d7d0d4478c38a4e521c3960833498fbb3a7d9e7658073a51f4938c46739ad657` | -| `@diffexai/diffex-tui@0.2.0` | `55fd31269758777f6603709b343592370de3a67b37b3229727a358ec83fabe9c` | diff --git a/docs/release/install-telemetry-removal.md b/docs/release/install-telemetry-removal.md deleted file mode 100644 index 3c282224a..000000000 --- a/docs/release/install-telemetry-removal.md +++ /dev/null @@ -1,45 +0,0 @@ -# Install telemetry removal - -The CLI no longer reports installations or changelog-detected updates to Pi. -This change is based on CLI PR #205 (`feat/staging-version-canaries`). -Inspection of `diffex-cloud` and `diffex-landing` found no install-report ingest implementation to remove. - -## Reproduction and regression boundary - -Run from `packages/coding-agent`: - -```sh -node ../../node_modules/vitest/dist/cli.js --run test/version-update-e2e.test.ts --maxWorkers=1 -``` - -The test starts the real source CLI with `--no-session --no-extensions --no-skills` in an isolated 100x30 PTY. -It supplies no provider credentials and intercepts every fetch before network access, with a socket guard for alternate HTTP clients. -Before removal, first install, upgrade, legacy opt-in, provider attribution opt-in, and normal startup attempted an anonymous `GET https://pi.dev/api/report-install?version=0.2.0` with a platform/runtime User-Agent. -Those five regression cases failed on the attempted request before the implementation changed. - -The initial removal passed eight cases: first install, upgrade, current checkpoint, legacy opt-in, legacy opt-out, provider attribution opt-in, offline, and startup with version checks enabled. -The retained coverage now combines these into four terminal journeys in `version-update-e2e.test.ts`: fresh install with a valid newer release, upgrade with legacy telemetry enabled and delayed network failure, current install with automatic checks disabled and provider attribution enabled, and fresh offline install. -The first two journeys contact only the canonical Diffex latest-version endpoint; the others make no requests. -The upgrade journey uses a temporary two-release changelog so it still exercises a real upgrade when the repository contains only one release entry. -Changelog acknowledgement still persists the current changelog checkpoint. -Each process renders confirmations for `/name ci-ready` and `/name ci-settled` before exiting through `/exit`. -The delayed response is released only after the first confirmation, and the second interaction follows response processing. -The harness verifies removal of each temporary home, project, settings, and event record while retaining only the compilation cache until suite completion. - -## Settings behavior - -The old install-telemetry setting, environment-variable reader, settings callbacks, and reporting helper are removed. -Optional provider headers now use `enableProviderAttribution` and `DIFFEX_PROVIDER_ATTRIBUTION`. -The independent setting defaults off, so removing the old shared opt-out cannot re-enable optional headers. -Explicit provider attribution opt-in never triggers an install report. -Provider identity strings, OpenCode session headers, runtime tracing, and experimental analytics are separate functionality. - -A real terminal smoke run opened `/settings`, searched for `attribution`, toggled **Provider attribution** from false to true, closed the selector, and exited. -The setting persisted and the recorded request list remained empty. -The settings selector tests also verify its compact layouts. - -## Release checks - -The staging client canary now rejects every unexpected startup request, including the former install-report exception. -The earlier [staging acceptance record](client-canaries.md) describes historical behavior before this removal. -This validation uses source clients with controlled HTTP responses; it does not deploy services or publish rebuilt CLI artifacts. diff --git a/docs/release/npm-install-scripts-review.md b/docs/release/npm-install-scripts-review.md deleted file mode 100644 index 10a079fb5..000000000 --- a/docs/release/npm-install-scripts-review.md +++ /dev/null @@ -1,61 +0,0 @@ -# Normal npm installation review - -## Status - -The public npm installation command is `npm i -g @diffexai/diffex`. -Static dependency review and isolated script-enabled installation checks support that command; scripts-disabled installation remains supported. -The modified Daytona scenario still requires explicit remote qualification and evidence review before automatic behavior runs are enabled or the remote path is treated as qualified. Its workflow is manual or qualified post-merge, not an automatic pull-request check; environment secrets remain restricted to `main`. -This record does not claim that GitHub Actions or Daytona has executed the changed tests. - -The normal-install follow-up changes instructions and acceptance coverage, not dependency versions, lockfiles, self-update behavior or publishing safeguards. -The generated npm README now shows the ordinary command; no install or postinstall hook is added to Diffex itself. - -## Reviewed scope - -The review compared the public `@diffexai/diffex@0.4.0` archive, the current CLI shrinkwrap, and the generated installer lock. -The public archive has 228 dependency entries; the current candidate graph has 140 because the reviewed Mistral provider implementation is bundled. -These are different artifacts even though they currently share version `0.4.0`. -The public archive must not be checked against the candidate's runtime inventory or source commit. - -Read-only metadata and tarball inspection covered 225 unique dependency identities across these graphs, including optional platform packages and required peer dependencies. -Each downloaded tarball matched its registry SHA-512 integrity. -No implicit `binding.gyp` installation or additional preinstall/install/postinstall hook was found. -The package staging code removes the CLI's and bundled workspaces' own scripts. -Build/prepare/publishing hooks declared in registry dependencies are not consumer-install hooks. - -Public CLI archive: - -```text -https://registry.npmjs.org/@diffexai/diffex/-/diffex-0.4.0.tgz -sha512-l76f89GQRAv4M2DOVc6CuT6RakqvK3d1bDwdtol9ciPJ8Lbi9p97pMDy7uuA3VDUUdR+8OlWk0pfEIkhapEZUw== -``` - -Only two install-time hooks were found: - -| Package | Hook | Reviewed behavior | -| --- | --- | --- | -| `@google/genai@2.31.0` | `preinstall`: `echo 'preinstall: no-op'` | Prints a message; no dependency build or download. | -| `protobufjs@7.6.5` | `postinstall`: `node scripts/postinstall` | Reads package metadata and may print a version-scheme warning; no network, subprocess, compilation or filesystem writes. | - -The published protobufjs `scripts/postinstall.js` SHA-256 is `5af8463b97ee8e309b4a2111f9479bacdf0c180de0ca0155527679b1fc6d9e6c`. -The existing exact-version allowlists in `scripts/generate-coding-agent-shrinkwrap.mjs` and `scripts/generate-coding-agent-install-lock.mjs` match these two packages. -An allowlist comment alone is not a replacement for reviewing the actual published hook bytes. -Re-review lifecycle behavior when dependency versions, peers, optional dependencies or the published package change. -This inspection does not prove that arbitrary future package versions or unrelated dependencies are safe. - -## Validation contract - -- The reusable artifact installer continues to disable lifecycle scripts by default. -- Native candidate acceptance explicitly enables scripts for npm package/process consumers and records that mode. pnpm and Bun acceptance remain scripts-disabled. -- Public-release npm initial and fresh installations require `ignore-scripts=false` and omit the flag from the install command; self-updates intentionally remain scripts-disabled. -- The explicit npm-only consumer option enables scripts and rejects opt-in for other package managers. -- Local audited fixtures prove that enabled npm hooks actually run, inherited credentials/configuration do not enter them, and scripts-disabled consumers remain disabled. -- Daytona's candidate npm consumers enable scripts and retain the full installed CLI behavior tests; Bun remains on its existing scripts-disabled path. -- The separate `npm-global-installation` scenario tests the retained candidate and independently verified public latest package in separate cold prefixes. -- Candidate registry metadata and seeded consumer installation locks preserve the reviewed shrinkwrap. npm 10 file/tarball transport can otherwise ignore a nested shrinkwrap and re-resolve transitive ranges; a real check caught unreviewed `protobufjs@7.6.6` before this correction. -- Both global installs require positive debug-log evidence that the exact two reviewed hook commands started and completed successfully. Configuration alone is not execution evidence. Raw npm logs are not retained. -- The public path executes exactly `npm i -g @diffexai/diffex`, with no `--ignore-scripts` flag and verified default configuration of `false`. -- Both global paths require executable resolution through PATH, expected version/help/models, exact installed runtime inventories and positive cleanup. -- Missing evidence, public latest movement, hash mismatches, scripts-disabled configuration or cleanup failure remain failures. - -See [the Daytona operator guide](../daytona-behaviors.md) for workflow authorization, execution, evidence and cleanup requirements. diff --git a/docs/release/promotion.md b/docs/release/promotion.md deleted file mode 100644 index 007469b04..000000000 --- a/docs/release/promotion.md +++ /dev/null @@ -1,147 +0,0 @@ -# Release promotion and recovery - -This implementation builds on CLI PR #209 and cloud PR #150. -The approved policy enables only the CLI npm package and defers standalone binaries, Python, secondary installers, and runtime images. -No binary or GitHub Release is created, and the private source repository stays private. -Enabling another channel fails closed until required asset enumeration, immutable publication, and anonymous byte verification exist for that channel. -The R2 schema remains the [npm release contract](contract.md), without a binary manifest or new client fields. - -## Source selection and review - -Only `patch` and `minor` releases are supported. -Patch covers fixes and additions; minor covers breaking changes. -The legacy major release alias and explicit-version invocation reject before mutation. -`scripts/release.mjs` validates one canonical fetch and push origin, a clean `main`, all public package versions, and the stable target version. -It fetches `origin/main`, requires exact `HEAD` equality, and checks local and remote target-tag absence before npm queries or source mutation. -Catalog source updates must already be reviewed and merged; release preparation hydrates model data without regenerating committed catalog source. -Only workspace manifests, root lockfiles, generated CLI shrinkwrap/install locks, and project changelogs may be staged. -Unexpected generated outputs stop the release. -The command displays diffs and requires the operator to inspect all generated changes and type the version in an interactive terminal before creating release refs. -Remote main is refreshed again after review. -The tagged release commit and next-cycle changelog commit are pushed together with `git push --atomic`. - -The CI source resolver fetches the named stable tag, resolves its commit once, verifies the push event commit when present, and checks that the release commit belongs to `origin/main`. -Every public package must match the tag version. -The private monorepo manifest has independent version metadata and does not define the public release version. -A supplied recovery ref must resolve to exactly the same commit, even when the workflow itself runs from another allowed ref. -Every downstream checkout uses the resolved full commit, including publication and announcement. -Recovery can fix workflow orchestration through a reviewed workflow ref, but it cannot substitute different release code or repack different bytes under the same version. - -## Promotion states - -| State | Required evidence and transition | -| --- | --- | -| Source verified | Canonical repository, immutable tag commit, main ancestry, matching versions, and equal recovery source. | -| Inputs frozen | Locked npm dependencies, pinned Node/npm, one hydrated model snapshot, and a private source archive with its SHA-256 digest. | -| Artifacts prepared | Build without catalog regeneration, checks, tests, disclosure inspection of every final tarball, and a manifest binding all input and output digests. | -| Artifacts retained | Successful immutable private Actions artifact upload before npm publishing receives credentials. | -| npm verified | Every existing or newly published package is anonymously downloadable and its size, SHA-256, and SHA-512 match the retained manifest. | -| Version recorded | Conditional creation of the immutable npm record, or exact identity verification of its existing bytes. | -| Announced | Conditional latest-pointer advancement without version regression; equal completion is a no-op. | - -The names describe verified prerequisites, not a mutable state field in the public contract. -The source archive and `inputs.json` freeze inputs before the build. -Packing rejects changes to tracked source or frozen model data, and `manifest.json` must agree with those original inputs. -Registry requests reject redirects and use thirty-second timeouts; tarballs are streamed with a 256 MiB limit. -Missing packages are eligible for publication, while mismatched existing bytes abort before publishing any missing package. -Announcement re-downloads the CLI tarball and verifies its bytes before either R2 write. -No failed prerequisite advances the pointer. - -## Retained artifacts and retries - -The private Actions artifact is named `private-release-` and contains the approved npm tarballs, `manifest.json`, `inputs.json`, and `source.tar.gz`. -The build manifest records Node/npm versions and the build platform, and binds the private source tree, lockfile, disclosure policy, and model files. -Private debug maps use a separate private artifact. -Both artifacts request ninety-day retention; repository or organization retention limits may shorten that availability. -Archive approved evidence in access-controlled long-term storage before expiry if longer recovery is required. -Neither the source archive nor debug maps are public release assets. - -Rerunning the original workflow downloads its existing artifact and verifies it instead of rebuilding or overwriting it. -A retry before any publication job ran may rebuild if no approved artifact was retained. -After publication has started, missing or expired retained evidence blocks reconstruction. -An operator must restore reviewed original evidence through a separately reviewed recovery path or prepare a new version; no digest mismatch is repaired by overwriting npm or an immutable release. - -For a recovery dispatch, select the original tag and original release run: - -```sh -gh workflow run build-binaries.yml --repo diffexai/diffex --ref main \ - -f tag=vX.Y.Z -f recovery_run_id=ORIGINAL_RUN_ID -``` - -The optional `source_ref` is an equality assertion against the release tag, never an alternative release candidate. -Recovery accepts artifacts only from the canonical repository's release workflow with a push or manual-dispatch event. -It copies the original artifact to the recovery run once; rerunning that recovery reuses the copy. -Ambiguous artifacts, expired evidence, failed downloads, and incomplete publication-job history stop recovery. -Publication consumes `node scripts/publish.mjs --artifacts .artifacts/release` with no rebuild or repack. -The bootstrap helper also retains exact checked bytes under `.artifacts/npm-bootstrap` and verifies existing versions before skipping them. -Keep that directory throughout bootstrap recovery. - -## Git push interruption - -Keep the two prepared commits and the local tag after an ambiguous or failed push. -Inspect both remote refs without changing them: - -```sh -git ls-remote origin refs/heads/main refs/tags/vX.Y.Z 'refs/tags/vX.Y.Z^{}' -node scripts/release.mjs --push-prepared vX.Y.Z -``` - -The recovery command requires clean main, a matching package version, the exact next-cycle commit immediately after the tagged release, no extra next-cycle files, compatible remote main ancestry, and no conflicting remote tag. -It repeats the atomic push without recreating commits or tags. -If the server accepted the original push but its acknowledgement was lost, repetition confirms the same refs without changing identity. -Conflicting remote refs require investigation and are never force-pushed. - -If a server explicitly reports that atomic pushes are unsupported, do not silently fall back. -After reviewing both refs and confirming no conflict, the operator may push `refs/heads/main:refs/heads/main`, fetch and verify that remote main contains the prepared release, and then push `refs/tags/vX.Y.Z:refs/tags/vX.Y.Z` without force. -A failure before the tag push leaves only the prepared branch commits remote and cannot start tag-triggered publication. -A failure after the tag push is resolved by reading the remote tag and workflow run before retrying the identical ref. -GitHub supports the normal atomic path; the command intentionally refuses automatic non-atomic fallback. - -## Publication and announcement failures - -| Failure | Recovery and preservation | -| --- | --- | -| Artifact upload fails | No publication job can start; fix private evidence retention first. | -| Some npm publishes succeed | Keep original artifact; verify every existing version against it and publish only absent versions. | -| Existing tarball differs or is unavailable | Stop; do not overwrite npm, skip verification, or announce. | -| New public channel lacks asset checks | Policy gate blocks promotion before registry or storage operations. | -| Version record succeeds but latest write fails | Re-verify npm and reuse the immutable record's original bytes and timestamp for the pointer retry. | -| Latest is already equal or newer | Preserve it; matching completion is safe to repeat and an older release cannot regress it. | - -Draft cleanup is unrelated to public recovery: the approved npm-only workflow creates no GitHub draft to delete. -Private temporary build directories may be discarded before publication if no retained evidence exists. -After any npm publication, retain evidence and immutable artifacts; never delete a version record to make a conflicting retry pass. -When standalone distribution is eventually enabled, draft cleanup must remain separate from public asset recovery and anonymous asset verification must precede announcement. - -## Validation and limits - -The initial reproduction ran the actual release command against an isolated clean feature branch with an unrelated origin and intercepted npm executable. -It reached npm without validating the branch or origin. -The same command now rejects the origin before npm. -The committed command regressions use temporary Git repositories and bare remotes, controlled registry metadata and tarball bytes, and intercepted npm/AWS/GitHub executables. -They cover dirty source, wrong branch/origin, local and remote divergence, existing tags, version mismatch, divergent recovery refs, partial npm publication, conflicting bytes, failed asset retention, pointer failure, and repeated completion. -Atomic-push tests exercise both a rejected server transaction and an accepted transaction whose acknowledgement is lost, followed by successful recovery using the same commits and tag. -Every fixture directory and local bare remote is removed in cleanup. - -Run from the CLI root: - -```sh -node --test scripts/release-source.test.mjs scripts/restore-release-artifacts.test.mjs scripts/publish-release-announcement.test.mjs scripts/npm-publish-plan.test.mjs scripts/release-contract.test.mjs -node --test scripts/*.test.mjs -npm run check -./test.sh -``` - -Run the cloud root and isolated public Worker `npm run check` gates and compare mirrored contracts with `node scripts/check-release-contract.mjs /path/to/diffex-new`. -Actionlint validates the release workflow. -These checks do not publish a real release, execute GitHub's credentialed publication jobs, or establish real installed-client acceptance. -The owner's changelog regeneration and actual approved-artifact/publication acceptance remain release prerequisites. -Standalone public asset publication failure cannot be exercised while that channel is deferred; the enabled-channel guard prevents pretending its assets were verified. - -Validation completed on 2026-09-12 with Node 22.19.0. -The CLI `npm run check`, complete `./test.sh` non-E2E suite, all script tests, and actionlint 1.7.12 passed. -The focused release commands were rerun after correcting the private-root/public-workspace version distinction and passed against both fixture and actual workspace inventory. -The cloud root gate passed 546 unit and 23 integration tests; the public Worker gate passed 352 tests, both deployment dry runs, and the dependency audit. -The mirrored contract check passed. -All release fixtures used temporary local directories, synthetic artifacts, and intercepted external operations, and cleanup completed. -No credentialed release workflow was dispatched, so there is no publication workflow run or real release manifest to cite for this implementation. diff --git a/docs/release/provider-branding-validation.md b/docs/release/provider-branding-validation.md deleted file mode 100644 index 059bda876..000000000 --- a/docs/release/provider-branding-validation.md +++ /dev/null @@ -1,67 +0,0 @@ -# Provider branding validation - -This record describes the provider presentation and attribution checks performed before the ChatGPT authentication migration. -Its OpenAI Codex registration, endpoint, device-flow, and transport descriptions are historical. -Current ChatGPT setup uses Diffex registration and the public model and Responses APIs; see [provider setup](../../packages/coding-agent/docs/providers.md#chatgpt). -The historical mock results below do not establish acceptance of the current ChatGPT flow. - -Diffex controls the local callback presentation and default application attribution. -Registered OAuth client IDs, provider consent pages, authentication protocols, stored credentials, inference endpoints, and payloads remain unchanged. -The separate Diffex account feature is excluded. -Radius was excluded from the original branding change and subsequently removed from the built-in provider and authentication integrations. -The [Radius removal record](radius-removal.md) supersedes its historical approval and documents configuration rejection and offline validation. - -## Implemented defaults - -| Integration | Branding | -| --- | --- | -| OpenAI Codex | Authorization and inference `originator=diffex`; native `diffex ( ; )` and browser `diffex (browser)` User-Agent | -| xAI | Device authorization `referrer=diffex` | -| OpenRouter | `HTTP-Referer: https://diffex.ai`, `X-OpenRouter-Title: Diffex` | -| NVIDIA NIM | `X-BILLING-INVOKE-ORIGIN: Diffex` | -| Cloudflare Workers AI / AI Gateway | `User-Agent: diffex-coding-agent` | -| OpenCode / OpenCode Go | `x-opencode-client: diffex` with the existing session header | - -Optional attribution still defaults off and honors `DIFFEX_PROVIDER_ATTRIBUTION`. -OpenCode session attribution remains independent of that setting and requires a session ID. -Explicit header overrides and `null` removal take precedence over branding defaults, including Codex originator and User-Agent. - -Codex, Anthropic, and OpenRouter callbacks use the Diffex four-loop mark and an inline pixel-art background based on the entrance animation's boat. -Success pages ask users to close the tab and return to Diffex without claiming credential persistence. -Handled errors use safe explanations instead of displaying raw provider responses. -Callback routes, validation, HTTP status codes, and response timing are preserved. - -## Offline verification - -A controlled Node capture used the actual login implementations and loopback callback servers with synthetic authorization codes and mocked token endpoints before changing presentation. -The baseline exposed provider-specific success claims on all three callbacks. -The updated pages were inspected in a browser at 375px and 1280px widths for success and error states, including spacing, contrast, wrapping, and horizontal overflow. -No real credentials or provider calls were used. - -Focused Vitest checks pass for callback presentation, Codex authorization and SSE/WebSocket streaming, Anthropic and OpenRouter OAuth, xAI polling and refresh, shared device polling, Copilot and Kimi OAuth, attribution merging, SDK attribution dispatch, and the provider selector. -The callback tests verify unchanged client IDs, scopes, redirect URIs, endpoints, PKCE, validation, and exchange timing. -Attribution tests cover enabled and disabled settings, environment overrides, configured and caller headers, explicit `null` removal, custom providers, and requests with and without session IDs. -A browser-bundled Codex stream also completed against a mock with `process` absent and the expected `diffex (browser)` User-Agent. - -`npm run check` passes, including the runtime identity inventory and browser bundle smoke check. -The inventory retains only two occurrences in one documented exception for the original DOOM integration credit. -The audit found no inherited Pi application labels in other built-in provider login implementations or shared login UI. -Provider protocol labels such as GitHub Copilot and Claude CLI headers remain unchanged. - -## Pending live compatibility checks - -No authorized provider accounts were used for live validation in this implementation session. -Mock results do not establish provider compatibility. -No exact provider rejection was observed; the following checks remain pending before release. - -| Integration | Required live checks | Status | -| --- | --- | --- | -| OpenAI Codex | Browser and device login, stored credential reuse and refresh, SSE and WebSocket inference | Pending | -| xAI | Device authorization with the new referrer, polling, stored credential reuse and refresh, inference | Pending | -| OpenRouter | Login, stored API-key reuse, inference with optional attribution enabled | Pending | -| NVIDIA NIM | Authenticated inference with `X-BILLING-INVOKE-ORIGIN: Diffex` and existing credentials | Pending; acceptance of the new value is unresolved | -| Cloudflare Workers AI / AI Gateway | Inference with each integration's existing credentials and optional attribution enabled | Pending | -| OpenCode / OpenCode Go | Inference with existing credentials and session/client attribution | Pending | - -Any rejection must be recorded with its exact incompatibility before claiming provider compatibility. -Do not resolve a branding incompatibility by changing OAuth registration, authentication protocols, or inference routing. diff --git a/docs/release/publication-trust.md b/docs/release/publication-trust.md deleted file mode 100644 index 27877fa32..000000000 --- a/docs/release/publication-trust.md +++ /dev/null @@ -1,136 +0,0 @@ -# Publication trust and bootstrap evidence - -## Supported npm identity - -The source repository remains private at `diffexai/diffex`. -Normal publication uses GitHub Actions OIDC with workflow `build-binaries.yml`, environment `npm-publish`, and direct `npm publish` permission. -Since 2026-09-03 npm automatically also grants `npm stage publish` on new trusted relationships. -The current inventory comes from `scripts/release-packages.mjs` and contains only `@diffexai/diffex`. -The owner approved replacing the initial eleven-package `0.2.0` bootstrap with CLI-only `0.2.1` and retiring the old versions after replacement validation. -No npm token secret belongs in the release workflow. - -[npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) supports private repositories, but does not generate provenance for them. -The publisher therefore sets `--provenance=false` explicitly, including when inherited npm configuration requests provenance. -It pins the public npm registry and publishes only retained, reviewed tarball bytes with lifecycle scripts disabled. -OIDC authentication does not supply public source provenance or make packaged implementation code confidential. -The supported verification path is the private source/input manifest, SHA-256 and SHA-512 tarball identities, downloaded registry bytes, and inspected npm trust relationships. -Source snapshots and debugging maps remain in private artifact storage. - -The workflow pins Node 22.19.0 and npm 11.16.0. -[npm trust](https://docs.npmjs.com/cli/v11/commands/npm-trust/) requires npm 11.15.0 or newer and an existing package. -Its verified JSON output must contain GitHub type, the exact repository/file/environment, and exactly `createPackage` plus npm's automatically granted `createStagedPackage` permission. -Missing, foreign, or broader relationships fail verification. - -```sh -node scripts/npm-publication-trust.mjs --plan -node scripts/npm-publication-trust.mjs --verify -``` - -The plan prints commands without registry writes. -Run the printed commands only after approved bootstrap bytes exist publicly. -The verifier queries every relationship and prints only configuration metadata. -Traditional publishing restrictions must be checked separately in every package's access settings after trust is confirmed. -An OIDC relationship alone does not disable token publishing. - -## Environment and credential boundaries - -The `npm-publish` environment was configured on 2026-09-13 and permits branch `main` and tags `v*` only. -The owner approved `Pepps233` reviewer protection with administrator bypass disabled, plus a ref-only fallback if the plan rejected reviewer protection. -GitHub returned HTTP 422 because the repository plan does not support required reviewers. -The approved fallback was applied and inspected; administrator bypass remains enabled and there is no enforced human review gate. - -| Destination | Environment | Credential scope | -| --- | --- | --- | -| Public npm artifacts | `npm-publish` | Per-package OIDC tuple; no stored npm publication token | -| Production release records | `diffex-release-upload` | `DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID` and `DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY`, production bucket only | -| Staging release fixtures | `diffex-release-staging` | Separately installed secrets with the same names, staging bucket only | -| Model catalogs | `diffex-model-upload` | Independent configuration; no release credentials copied | -| Production Worker deployment | Cloud repository `public-api-production` | `CLOUDFLARE_PUBLIC_API_DEPLOY_TOKEN`; no R2 publisher keys | - -The approved public asset host is npm. -Standalone and alternate-host assets are deferred, so the unused `diffex-distribution` environment requires no publisher credential. -Production release storage is `diffex-artifacts`; staging is `diffex-artifacts-staging` in the reviewed Cloudflare account. -Existing buckets and credentials must be reused. -Bucket scope grants access to the entire selected bucket; the release prefix remains a code boundary. - -The manual verification workflow now checks each release credential independently using read-only bucket listings. -Each key must access its own bucket and receive explicit `AccessDenied` for the other release bucket and `image-converter`. -Timeouts, missing buckets, and invalid credentials cannot count as isolation evidence. -The production check makes no writes; staging fixture verification retains exact ownership checks and cleanup. -The new scope checks require a run after this workflow change reaches `main`, without relaxing the existing environment restrictions. - -## Live evidence recorded 2026-09-13 - -CLI candidate `a25f52bc2` contains the merged distribution, promotion, and native acceptance work. -Anonymous registry inspection at 14:21 UTC returned 404 for all eleven package names at current lockstep version `0.2.0`. -No versions or dist-tags were present. -The owner previously verified npm account prerequisites; this inspection did not repeat login or ownership checks. - -GitHub inspection confirmed private source visibility, existing release environments, production `main`/`v*` and staging `main` policies, and the separate secret entries installed on 2026-09-10. -The distribution and model-upload environments had no secret entries. -The cloud production environment had its separate Worker deployment secret. -Secret-name inspection does not prove credential values differ or establish current bucket capability. - -[Staging run 34762316585](https://github.com/diffexai/diffex/actions/runs/34762316585) passed on `main` using the existing workflow with `check_public_api=true` and `check_client_canaries=false`. -It exercised controlled npm responses, real staging R2 writes/readback, repeated publication, conditional advancement, interrupted-pointer recovery, and anonymous HTTP projection and rejection behavior. -Fixture versions were `0.1789309179924.0` and `0.1789309179924.1`. -At 14:20:48 UTC it confirmed both version records, the latest fixture pointer, and `verification/release-publication.lock` were absent. -It made no production write and did not publish npm packages. -This run predates the additional credential scope checks and does not claim to validate them live. - -## Bootstrap decisions and replacement - -The owner confirmed the current `/cl` refresh and approved public bootstrap publication of `0.2.0` once validation passes. -The initial environment attempt was blocked by automatic approval review; the owner subsequently approved the exact settings and fallback, and configuration succeeded. -The historical shared-key sentence refers to R2, not Worker deployment. -It has no retained credential identifier, location, or current-state evidence and is not an actionable revocation requirement. -No credential was revoked or replaced on the strength of that note. -All eleven original `0.2.0` packages were uploaded from native-accepted source `e3f96b95ebc7fdad96220e315ec144f74383de22`. -Registry read-back unexpectedly showed both `bootstrap` and `latest` on all names despite explicit `--tag bootstrap`; a reviewed removal attempt returned HTTP 400. -This failed the no-latest acceptance gate and no production announcement was made. -The CLI trusted publisher was created and read back with the exact repository, file, environment, and current npm permission pair. -The owner then requested a CLI-only distribution and approved `0.2.1` with retirement of `0.2.0`. -The replacement tarball includes seven required workspace packages plus the existing evolution and harness-trace runtime modules. -The final publication and retirement results below supersede the initial eleven-package bootstrap. -No R2 production pointer or generally available release may be created by bootstrap. -Credential scope verification remains dependent on the workflow reaching `main`. - -## CLI-only replacement validation - -Final publication candidate `f2a661684af35fe6e9a3209c6f9720144e1fd423` packages only `@diffexai/diffex@0.2.1`. -The [local candidate evidence](cli-only-candidate-evidence.json) retains its exact tarball size, SHA-256, SHA-512, supported entrypoints, npm/Bun acceptance, terminal outcomes, and cleanup. -The source quality gate, full isolated `./test.sh` suite, package inventory regressions, and disclosure-policy rejection tests passed. -The final local tarball passed both package managers with the separate Diffex registry scope blocked. -These bytes were retained before publication and selected for the owner-approved replacement. - -[Native run 34765040811](https://github.com/diffexai/diffex/actions/runs/34765040811) passed Darwin arm64 and both Linux targets on implementation commit `8c8d8f529`; Darwin x64 was cancelled when the final README revision was pushed. -[Final candidate run 34765173973](https://github.com/diffexai/diffex/actions/runs/34765173973) failed before executing any build command. -GitHub's job annotation reports failed account payments or an Actions spending limit and directs the owner to Billing and plans. -Rerun that retained run after the account issue is resolved so source identity remains pinned to the final candidate. -The owner subsequently explicitly approved publication without the blocked GitHub CLI validation. -The exact final local tarball recorded in `cli-only-candidate-evidence.json` was published with the incomplete native matrix retained as an owner-accepted limitation. -Future normal releases retain their native acceptance gate. -Do not substitute the earlier partial matrix or represent local validation as four-platform acceptance. - -## Published CLI and retirement results - -On 2026-09-13T15:44:11.177Z, anonymous registry verification confirmed only `@diffexai/diffex@0.2.1` remains from the approved original inventory. -The ten separate npm packages and CLI `0.2.0` were unpublished in dependency order, and every retired version endpoint returns 404 or 410. -The replacement was downloaded anonymously and matches source `f2a661684af35fe6e9a3209c6f9720144e1fd423`, SHA-256 `64c161d63ef416f6bf116a6b35953225dc49c236ccba0ef4b88c2ac8ff4452b0`, and the retained SHA-512 integrity. -Final registry inspection found `bootstrap: 0.2.1` and `latest: 0.2.1`. -The npm registry rejected removal of its automatic latest tag; the no-latest acceptance condition remains unresolved. -npm accepted `npm access set mfa=publish @diffexai/diffex`, requiring publishing 2FA and disallowing traditional token overrides. -The exact retained CLI trusted publisher was inspected again after package retirement. -Fresh anonymous npm and Bun installations passed public imports, installed-file hashes, CLI version/help, native modules, WASM, and HTML export on Darwin arm64, with isolated directories removed afterward. -The [publication evidence](cli-publication-evidence.json) retains exact identity, registry retirement results, trust, tags, and installation outcomes. -The earlier [candidate evidence](cli-only-candidate-evidence.json) describes the retained prepublication validation snapshot. -No R2 production pointer or general release announcement was created. -The production probe after publication returned missing release (404), healthy readiness (200), and anonymous auth rejection (401). - -## Remaining live verification - -The new production/staging credential scope checks require the companion workflow on `main` and a subsequent dispatch. -Existing environment ref restrictions were preserved. -The npm no-latest acceptance condition remains open because tag deletion was rejected. -GitHub native validation of the final candidate remains an explicitly owner-accepted exception for this replacement only. -Future normal releases retain their native validation gate. diff --git a/docs/release/publication.md b/docs/release/publication.md deleted file mode 100644 index bf30510ae..000000000 --- a/docs/release/publication.md +++ /dev/null @@ -1,178 +0,0 @@ -# Verified release publication - -## Ownership and release order - -The CLI monorepo owns npm verification and release-record publication through `scripts/publish-release-announcement.mjs` and `.github/workflows/build-binaries.yml`. -The independent public API Worker in `diffex-cloud` consumes these records; Worker, Render, and landing deployments do not advance them. - -The release workflow verifies one immutable source commit, freezes model data and source inputs, builds the approved npm packages, retains their exact bytes, publishes missing versions, and announces verified npm availability. -The R2 marker means all public workspace packages were downloaded at their exact release version and matched the retained approved artifact digests. -Standalone binaries remain deferred, and the npm record contains no binary manifest. -See [promotion and recovery](promotion.md) for the complete prerequisites, private artifact retention, and partial-failure procedures. -This setup does not authorize running a release or bypass the repository's release requirements. - -See [publication trust and bootstrap evidence](publication-trust.md) for the private-source OIDC decision and the current live verification record. - -## Credentials and environment boundaries - -Release storage belongs to account `6b24afa849f8254ab5e6e2d1956239cc`. -The publisher endpoint is `https://6b24afa849f8254ab5e6e2d1956239cc.r2.cloudflarestorage.com`. - -| Environment | Bucket | Credential | -| --- | --- | --- | -| `diffex-release-upload` | `diffex-artifacts` | Dedicated production Object Read & Write token restricted to this bucket. | -| `diffex-release-staging` | `diffex-artifacts-staging` | Separate staging Object Read & Write token restricted to this bucket. | - -Each environment stores `DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID` and `DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY` as GitHub environment secrets. -The workflow maps them to `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` only in the corresponding job. -Production environment policies allow the `main` branch and `v*` tags; staging allows only `main`. -The repository's GitHub plan rejected required-reviewer protection with HTTP 422. -The owner approved branch/tag restrictions without required reviewers on 2026-09-10. -GitHub reports `can_admins_bypass: true`; there is no enforced human approval gate. -Review release tags and any manually selected recovery source before dispatching a workflow. -These restrictions apply to the workflow ref; the resolver additionally rejects a recovery `source_ref` that differs from the immutable tag commit. -Missing credentials fail before npm verification or storage access. -Use an AWS CLI that supports `put-object --if-match` and `--if-none-match`. - -The original `diffex-model-upload` environment had no secrets or protection rules when inspected on 2026-09-10. -That historical setup has been superseded by the dedicated model-upload environments and Diffex-owned storage described in [the catalog contract](../model-catalog.md). -Release publication and model publication use separate environment credentials. -Do not copy release credentials into model-upload environments. - -Bucket-scoped Object Read & Write credentials can mutate other objects within their own bucket, including any future model objects in the production bucket. -They do not enforce a `releases/v1/` prefix boundary and must never be distributed to clients. -The publisher itself uses fixed release-record keys. -See [Cloudflare R2 token permissions](https://developers.cloudflare.com/r2/api/tokens/) for the storage capability boundary. - -Users call the public version endpoint without R2 credentials. -The deployed staging Worker accesses private storage through a native binding, which supports both reads and writes. -If enforced read-only storage access is required, configure a separate read-only credential per Worker environment during endpoint implementation. - -## Publication invariants - -The [stable release contract](contract.md) specifies the exact stored schema and public HTTP projection. -The publisher now enforces that schema for both new and existing records. -The unchanged consumer fixture suite verifies the deployed npm record shape and public response projection. - -Before any write, the publisher inventories every non-private workspace package and requires its version to match the requested canonical stable semver. -It checks exact npm package identity and version, downloads every canonical tarball without redirects, and compares its size, SHA-256, and SHA-512 with the retained manifest. -Requests have a 30-second timeout, npm propagation retries have a ten-minute budget, and storage commands have a one-minute timeout. - -`releases/v1/releases/.json` is created with `If-None-Match: *`. -On a repeated announcement, the existing record must match the verified version, commit, and package metadata. -An interrupted announcement reuses that record's original bytes and timestamp for `releases/v1/latest.json`. -A conflicting immutable record fails without replacing it. - -The latest-pointer update reads the body and ETag from one GET response and uses `If-Match` for replacement or `If-None-Match: *` for first publication. -Lost races retry at most five times, rereading the current version before each attempt. -Equal or older announcements cannot replace a newer pointer. -Malformed existing pointers and storage permission errors fail closed; they are not treated as an absent release. -Provider diagnostics are withheld from errors to avoid exposing credentials or signed request details. - -If npm verification fails, neither record is written. -If the versioned write succeeds but the pointer update fails, rerun the announcement for the same source commit after correcting the failure. -Never delete a production immutable record to force a retry or lower the latest version as ordinary recovery. - -## Controlled regression and staging verification - -Run from the CLI repository root: - -```sh -node --test scripts/publish-release-announcement.test.mjs -npm run check -./test.sh -``` - -The command-level regression launches the actual publisher with isolated manifests representing every public workspace package. -A test-only preload controls registry and tarball responses and rejects other Node network connections. -Default tests intercept the AWS executable as well, so they require no SaaS credentials and write no remote objects. -Before the fixes, this reproduction failed for missing credentials, interrupted-record consistency, and conflicting source commits. - -To deliberately verify real storage, supply the staging key through the environment and run: - -```sh -node scripts/verify-release-publication.mjs --staging -``` - -The staging command fixes the reviewed account and staging bucket in code, uses controlled npm responses, and executes the real AWS CLI for storage. -It never publishes npm packages or runs the release workflow. -The smoke test checks failed verification before first publication, matching record creation, safe repetition, failed verification after publication, conditional advancement, older announcements, and interrupted-announcement recovery. - -The verifier acquires `verification/release-publication.lock` conditionally and refuses to start if the latest pointer or selected version keys already exist. -It uses synthetic versions only in staging, tracks exact keys, and deletes only objects bearing its unique run identity. -Cleanup verifies every fixture key is absent and removes the lock. -If cleanup fails, the lock remains for investigation; inspect its recorded keys and ownership before removing anything. -Do not empty the bucket or delete another run's records. - -`.github/workflows/verify-release-publication.yml` exposes the same verification as a manual-only workflow using the staging environment. -Publisher-only verification requires no dependency installation and is excluded from default PR CI. -The optional client canaries install the locked dependencies with lifecycle scripts disabled. -Dispatch it from `main` once the workflow is merged; adding a workflow on a pull request does not make it dispatchable from the default branch. - -## Verification evidence - -On 2026-09-10, both newly supplied keys successfully listed release records in their own buckets and received `AccessDenied` for the other release bucket and the unrelated `image-converter` bucket. -Both release namespaces were empty before the staging check. -The original record referred to a previous shared R2 key that was not installed in GitHub. -That record retained no credential name, identifier, location, or confirmed scope, so it does not establish that an active credential exists or justify revocation. -Only an identified, verified superseded credential could be considered for owner-directed retirement; the current dedicated R2 keys and Worker deployment token are unaffected. -The 14 focused publisher regressions, `npm run check`, `./test.sh`, and actionlint 1.7.12 checks of both changed workflows passed. -No real release was run. - -Live staging verification passed with AWS CLI 1.46.1 and Node 22.19.0 using the dedicated staging key. -The actual publisher ran with controlled npm metadata for all 11 public workspace packages and real R2 requests. -The fixture versions were `0.1789059755304.0` and `0.1789059755304.1`. -Both matching records, byte-preserving repeated publication, unchanged pointers after verification failures, conditional advancement, older-announcement rejection, and interrupted-record recovery passed. -The exact cleanup covered `releases/v1/latest.json`, `releases/v1/releases/0.1789059755304.0.json`, `releases/v1/releases/0.1789059755304.1.json`, and `verification/release-publication.lock`; each was confirmed absent afterward. - -The GitHub environment secret names and timestamps were verified after secure installation on 2026-09-10 at 17:03 UTC. -No secret values were printed, committed, or copied into client or Worker configuration. -This verifies credential configuration and real publisher-to-R2 behavior; a GitHub-hosted staging run remains available after the new manual workflow reaches `main`. - -A final credential-isolation check confirmed both release namespaces remained empty after staging cleanup. - -## Deployed public API verification - -The optional `--public-api` flag adds actual unauthenticated HTTP checks against the fixed staging Worker: - -```sh -node scripts/verify-release-publication.mjs --staging --public-api -``` - -The manual workflow exposes the same flag as `check_public_api`, disabled by default. -Only the CLI staging environment supplies publication credentials; the Worker deployment workflow never receives them. -The verifier tests canonical projection, missing releases, malformed JSON, unsupported schema, prereleases, ignored query selectors, rejected methods and suffixes, readiness, and unchanged storage after HTTP requests. -It restores malformed fixtures conditionally and removes only records owned by the run. -A final HTTP failure still releases the lock after successful object cleanup; actual storage cleanup failure retains the lock for investigation. - -Live verification passed on 2026-09-10 using synthetic versions `0.1789069419298.0` and `0.1789069419298.1`. -The real publisher used controlled npm responses for all 11 packages, real staging R2, and the deployed Worker. -Both release keys, the latest pointer, and the shared lock were confirmed absent afterward. -The production latest record remained absent. -The [cloud staging acceptance record](https://github.com/diffexai/diffex-cloud/blob/feat/serve-verified-releases/docs/public-api-staging.md) records Worker deployment identities, the separate missing-binding failure and recovery test, and unchanged Render routing. -This proves publisher-to-R2-to-HTTP behavior; the installed CLI authority migration and canary remain separate work. - -## Source CLI staging canaries - -Use the staging-only publication credentials and an AWS CLI supporting conditional writes: - -```sh -node scripts/verify-release-publication.mjs --staging --client-canaries -``` - -The manual workflow exposes this as `check_client_canaries`, disabled by default, and automatically includes the HTTP checks. -The command uses the same exclusive staging lock, ownership checks, conditional writes, and verified cleanup as publication verification. -It adds manual and interactive source CLI clients while each controlled record is present in real R2. -Each client gets an isolated home, project, credentials directory, and simulated npm prefix. -The test-only preload maps the exact production version URL to the fixed staging HTTPS endpoint and rejects redirects. -It permits sockets only to that staging hostname on port 443, intercepts every package-manager subprocess, and never passes storage credentials into the client. -The production client has no endpoint override. - -Canaries cover missing releases, newer/equal/older releases, normal and forced updates, manual retries, offline mode, automatic opt-out, and quiet interactive startup and `/exit` in a real 100x30 terminal. -Malformed JSON, wrong schema, prerelease versions, missing or duplicate canonical packages, package-version mismatches, and foreign identities must produce an unavailable response and no install even with force. -Every successful install attempt must match the complete expected npm argument list, pinned to `@diffexai/diffex@` without uninstall. -The JSON evidence captures requested and actual URLs, response status and public body, socket destination, intercepted install command, and verified local cleanup. -Install telemetry is removed, and the current canary rejects every unexpected startup request, including the former install-report route. -See [install telemetry removal](install-telemetry-removal.md) for the regression boundary; the original acceptance results remain historical evidence. -The source canary does not establish published-package installation or binary acceptance. -See [the client canary acceptance record](client-canaries.md) for executed results and remaining limits. diff --git a/docs/release/radius-removal.md b/docs/release/radius-removal.md deleted file mode 100644 index 6f9555661..000000000 --- a/docs/release/radius-removal.md +++ /dev/null @@ -1,68 +0,0 @@ -# Radius removal - -Radius must not be used in development, testing, evaluation, or production. -The removal supersedes the older candidate handoff and rehearsal approvals. -Historical JSON evidence remains a record of the original bytes, not an approved release candidate or permission to use Radius. - -## Configuration behavior - -The common rejection explains that Radius support was removed and requires selecting another provider. -The runtime rejects Radius registrations before changing provider or credential state. -It does not migrate Radius traffic to another provider automatically. - -| Existing state or request | Result | -| --- | --- | -| Saved API key, OAuth credential, environment key, or model cache | Remains inactive; no refresh, discovery, authentication, or inference request | -| `providers.radius` in `models.json` | Configuration error naming the entry and file, including configurations without OAuth | -| `oauth: "radius"` under another provider ID | Same configuration error before schema validation or provider composition | -| CLI provider/model selection or Radius model scope | Explicit removal error before inference | -| Saved default provider | Startup fails with instructions to update `defaultProvider` and `defaultModel` in `settings.json` | -| Resumed Radius session | Startup fails with instructions to choose a replacement using `--model provider/model` | -| Auth check or credential printing | Explicit removal error; no Radius credential is exported | -| Extension or SDK provider registration | Rejected, including case variants of the removed provider ID | -| `/login` | Radius absent from both subscription and API-key selectors | -| `/logout` with a saved Radius credential | Local credential deletion remains available without contacting the former service | - -Invalid Radius entries invalidate the custom configuration file; built-in providers remain registered. -Unselected stale credentials and caches do not prevent use of supported providers. -See the [user migration steps](../../packages/coding-agent/docs/providers.md#removed-providers). - -## Source and artifact review - -The built-in provider factory, OAuth implementation and loader, environment-key mapping, provider type, and default-model integration were removed in `3cd84966f`. -`f1a3608e1` closes custom-configuration and registration paths and adds actionable selection errors. -`19d038f8b` adds the archive gate and checks legacy CLI and session behavior. -`27b844ad3` presents expected startup rejections without a Node stack trace. -The generic `diffex-messages` transport remains available for other backends, with neutral local test fixtures. - -The remaining source references identify the removed provider for rejection or assert that access is unavailable. -Documentation records migration policy or explicitly superseded historical evidence. -CSS `border-radius` properties are unrelated. -The evaluation configuration and workflows contain no Radius integration. - -The distribution checker rejects former provider/OAuth module paths and service, environment-key, or client-registration markers in executable payloads and declarations. -The same inspection applies to bundled internal workspaces and nested archives. -The removal explanation itself is permitted. -Old ignored local build outputs were cleaned and regenerated offline rather than edited manually. - -## Validation - -Validation uses synthetic credentials, local fixtures, and isolated configuration directories. -No Radius service, real provider credential, paid inference, or live Radius compatibility test is used. - -- `npm run check` passes, including identity, dependency, lockfile, TypeScript, and browser checks. -- `./test.sh` passes with provider credentials removed and live E2E tests disabled. -- Focused AI tests cover provider discovery, environment-key removal, registration rejection, and the generic message transport. -- Focused coding-agent tests cover stale credentials/caches, configuration, registration, selection, defaults, resumption, auth commands, both login methods, and local logout cleanup. -- The distribution payload suite passes all 47 tests, including stale modules and markers in renamed or bundled payloads. -- Fresh offline compilation and reviewed packing produce a local tarball that passes payload inspection. -- A clean npm consumer installs that tarball with lifecycle scripts disabled and verifies every runtime hash against the reviewed archive. -- The installed executable passes help, version, model listing, controlled print and terminal prompt/reply, Radius search exclusion in both login methods, and clean exit. -- Installed legacy configuration, defaults, resumed sessions, and auth commands produce the removal explanation. - -The terminal fixture answers the logical Diffex version request locally and rejects unexpected provider requests and socket connections. -This establishes removal and local package behavior; it does not publish a release or establish unrelated live-provider compatibility. - -The tested runtime source is `27b844ad3` on macOS arm64 with Node 22.19.0 and npm 10.9.3. -The local `0.3.3` tarball contains 1,243 payload files and has SHA-256 `7bacd1bf5fdebb4a0097ad683cf6cf26fc497b0a469df0e72231f318be5bfb71`. -This checksum identifies validation bytes only, not a published replacement for an existing version. diff --git a/docs/release/runtime-validation.md b/docs/release/runtime-validation.md deleted file mode 100644 index 997f6784d..000000000 --- a/docs/release/runtime-validation.md +++ /dev/null @@ -1,74 +0,0 @@ -# Hosted runtime candidate validation - -The owner confirmed on 2026-09-13 that no staging deployment or Daytona consumer is configured. -Local runtime validation is the available acceptance boundary. -It does not promote a hosted deployment or establish a prior-runtime rollback baseline. -The release remains npm-only; this image is not an interactive CLI installation channel. - -## Build and inspect locally - -Run from the private source checkout with Docker running: - -```sh -DIFFEX_RUNTIME_COMMIT="$(git rev-parse HEAD)" -docker build --platform linux/amd64 \ - --build-arg "SOURCE_COMMIT=$DIFFEX_RUNTIME_COMMIT" \ - --file Dockerfile.runtime --tag diffex-runtime:candidate . -docker run --rm --platform linux/amd64 --network none \ - --mount "type=bind,src=$PWD/scripts/fixtures,dst=/acceptance,readonly" \ - --entrypoint node diffex-runtime:candidate /acceptance/runtime-smoke.mjs -docker save --output /tmp/diffex-runtime-candidate.tar diffex-runtime:candidate -python3 scripts/check-runtime-image.py /tmp/diffex-runtime-candidate.tar -docker inspect diffex-runtime:candidate -shasum -a 256 /tmp/diffex-runtime-candidate.tar -``` - -Use a clean committed source for retained acceptance and record its full commit. -Dirty-checkout results must also identify the uncommitted diff and cannot be attributed solely to the base commit label. -The Dockerfile pins the Node 24.16.0 base manifest digest and uses Linux amd64. -It hydrates validated model data, compiles workspace outputs, prunes development dependencies, and stages the required hosted runtime dependency closure. -The image carries compiled files, native runtime assets, the explicit user-documentation allowlist, and legal notices. -Project build inputs and private development documentation stay in the build stage. -Third-party redistribution materials remain with dependencies. - -The fixture is mounted read-only for testing and never copied into a distribution layer. -It launches the actual server executable, uses a mock model provider and usage endpoint, verifies anonymous and incorrect-credential denial, rejects another session ID, exchanges a prompt and reply, and restarts using persisted session data. -External networking is disabled by Docker. -The test removes its directories and the container exits cleanly. -The layer checker examines every saved image layer so deleting an internal document in a later layer cannot hide its inclusion. - -The manually dispatched Runtime Image workflow runs the same build, smoke, and layer checks on Linux amd64. -It retains the tested Docker archive, local image ID, archive checksum, and evidence in private Actions storage for 14 days. -It has read-only repository permissions and no registry publishing job. -There is no automatic main-branch image publication. - -## Local evidence - -On 2026-09-13, the development candidate passed the Linux amd64 build and smoke on Docker running on Darwin arm64. -The initial local image ID was `sha256:cfd4495ec712460a1400a1f6dda7f4352b7e48a659bed79505cf0c2f3714aad9`. -Its source label identifies base commit `06830a99220c3dad14292830171c433c916d49b1`; the branch's uncommitted runtime and packaging changes were also build inputs. -Layer inspection accepted 1,426 project files across 12 runtime workspaces and all 30 approved documentation assets. -This development image is not a registry release digest or a promoted artifact. -Final committed-candidate workflow evidence belongs in the implementation PR. - -The npm lifecycle harness also passed on Darwin arm64 with Node 22.19.0, npm 10.9.3, and Bun 1.3.11. -It used the local candidate tarball, real npm installation processes, controlled release discovery, and a loopback registry for the canonical package. -Malformed release inputs and checksum mismatch preserved the installed prefix. -An interrupted download changed npm's prefix and required moving the exact stale backup before a pinned repair succeeded. -Bun global installation, PATH, real self-update, installed-byte verification, foreign-identity rejection, and removal also passed. -Removal retained user and project data, and all test-owned directories were removed. -Its version-metadata predecessor is a simulation; public-to-public upgrade acceptance remains separate. - -## Registry and deployment prerequisites - -Keep the future image private at `ghcr.io/diffexai/diffex-runtime` and configure scoped publisher and pull identities before publication. -Review the base-image and dependency license inventory, generate OCI SBOM and provenance attestations, and retain the image's registry manifest digest with its exact source and model-data inputs. -Docker's local exporter does not retain attestations, so the local archive does not claim SBOM/provenance acceptance. -Use a registry or OCI exporter for attested artifacts as described in [Docker's attestation documentation](https://docs.docker.com/build/metadata/attestations/). - -The first hosted acceptance must pull the exact candidate from the intended staging environment, create a Daytona snapshot from its digest, and configure the cloud consumer explicitly. -Record the cloud commit, runtime commit, registry digest, snapshot, deployment owner, architecture, pull identity, and approved credential source without credential values. -Exercise real cloud allocation, attachment, streaming, reconnect, stop, cross-actor denial, and cleanup. -Only then retain the tested digest and snapshot as the first recovery baseline. -Every replacement must keep its previous tested configuration available, demonstrate restoration, and verify cleanup before retirement. -Registry publication, attestations, real provider inference, deployment promotion, and prior-runtime rollback are not completed by local testing. diff --git a/docs/release/self-update-target.md b/docs/release/self-update-target.md deleted file mode 100644 index 57e9512c4..000000000 --- a/docs/release/self-update-target.md +++ /dev/null @@ -1,77 +0,0 @@ -# Diffex version discovery and canonical self-update - -Interactive startup and manual self-update share `https://api.diffex.ai/api/latest-version` as their only version authority. -The request uses a Diffex user agent, JSON accept header, omitted credentials, and disabled caching. -There is no endpoint setting or environment override in production code. -There is no Pi, npm latest-tag, or alternate-source fallback. -All redirects are rejected without following their location, including same-origin redirects. -This keeps request forwarding restricted to the exact reviewed endpoint. - -The shared checker validates the untrusted JSON with `isLatestVersion` from the [release contract](contract.md). -Only HTTP 200 JSON containing exactly a canonical stable `version` and `packageName: "@diffexai/diffex"` is accepted. -Missing or foreign identities, arrays, null, unknown fields, URLs, npm aliases, prereleases, build metadata, leading zeroes, leading `v`, whitespace, and trailing newlines are rejected before comparison. -The checker also rejects numeric components outside npm semver's supported range instead of rounding them. -The manual planner independently checks the stable version and identity before the force decision, installation detection, Windows dependency preparation, and process dispatch. -It constructs the installation specification from the local `RELEASE_PACKAGE_NAME` constant and the validated version. -Normal and forced valid updates attempt only `@diffexai/diffex@` without a package-migration uninstall. -Equal or older releases do not install unless force is requested. - -## Failure and opt-out behavior - -Automatic checks make at most one request and remain silent on failure. -Manual checks retain at most two additional attempts for transient HTTP or transport failures. -All attempts share a ten-second deadline, which also bounds consumption of the response body. -Redirects, invalid payloads, and absent releases do not trigger retries. -`DIFFEX_OFFLINE` disables both automatic and manual discovery, including forced updates. -`DIFFEX_SKIP_VERSION_CHECK` disables only automatic discovery; explicit manual checks still run. - -| Condition | Manual result | -| --- | --- | -| Exact HTTP 404 JSON `{"error":"no_published_release"}` | `No published Diffex release is available yet.` | -| Other HTTP errors, redirects, wrong content type, invalid JSON, or body failure | `Diffex update service is unavailable. Try again later.` | -| Invalid success schema, identity, or version | `Refusing self-update: expected a stable @diffexai/diffex release.` | -| Transport failure or request timeout | `Diffex update service is unavailable: ` | -| Offline | `Could not determine latest diffex version.` | - -Each failure exits manual update with status 1 before package mutation. -An unrelated 404, HTML error page, or unavailable service cannot be presented as an intentionally absent first release. -Server response bodies and redirect destinations are not copied into errors. - -## Update notes - -The former checker accepted an optional remote `note` field, and both manual and interactive code contain intentional note rendering. -The current stored record and HTTP projection have no note field. -The consumer therefore rejects responses containing notes as unsupported contract extensions. -The `LatestDiffexRelease.note` display capability, manual Markdown renderer, and interactive notification renderer remain intact for a future coordinated contract extension. -This migration does not invent an unverified note source or enable the currently disabled automatic update notification. - -## Reproduction and verification - -On 2026-09-10, the existing 18-case controlled source-CLI suite passed on the parent branch `feat/pin-self-update-package` before the authority change. -New CLI regressions then reproduced the Pi URL, default redirect handling, and identical generic errors for no release and service failure. -They also captured acceptance and rendering of an unsupported note before intercepted installation. -The earlier unsafe foreign-package reproduction remains documented in the [historical reproduction record](version-update-reproduction.md). - -Run focused validation from `packages/coding-agent`: - -```sh -node ../../node_modules/vitest/dist/cli.js --run test/version-check.test.ts test/version-update-e2e.test.ts test/package-command-paths.test.ts test/release-contract.test.ts --reporter=verbose -``` - -The checker suite applies the shared HTTP corpus through the production consumer and covers strict JSON content types, unsupported semver ranges, exact no-release classification, retries, timeout cancellation, offline mode, and automatic opt-out. -Redirect tests run the real Undici fetch implementation against an isolated `MockAgent` with external network access disabled and assert zero requests to the redirected host. -The source CLI suite retains four terminal journeys and three manual update scenarios for canonical installation, forced foreign-package rejection, and offline suppression. -The command integration suite exercises the real handler and validation with isolated settings, controlled HTTP responses, and a recording installer for detailed response permutations, retry exhaustion, equal and older versions, forced reinstall, and manual checks after automatic opt-out. -Interactive cases use a real 100-column, 30-row PTY, verify rendered `/name` confirmations around discovery completion, and exit through `/exit` without submitting model prompts. -The harness isolates home, credentials, project resources, and package prefix, blocks alternate network paths, intercepts package-manager execution, and removes every temporary directory. -All accepted installation attempts are pinned to the canonical package and explicit validated version; no install or uninstall executes. - -The original migration's final checker and source-CLI run passed 101 cases: 69 checker cases and 32 source-CLI cases. -The package-command integration and consumer fixture suites also passed all 31 and 23 cases respectively. -`npm run check` and the complete isolated `./test.sh` suite passed. -The Cloud-to-CLI contract document and fixture comparison passed, and the executable consumer paths contain no Pi version endpoint. -Publisher and Worker contract fixtures remain unchanged. -The source consumer and test harness need no Cloud or landing application changes. -Controlled fixtures are supplied only by the test preload or isolated fetch dispatcher, never through a production authority override. -These checks establish source-CLI behavior, not a deployed R2-to-Worker-to-CLI canary or acceptance of a published client. -Live staging client canaries, production routing activation, and release publication remain separate work before distributing the migrated client. diff --git a/docs/release/source-publication.md b/docs/release/source-publication.md deleted file mode 100644 index 0175c2e05..000000000 --- a/docs/release/source-publication.md +++ /dev/null @@ -1,119 +0,0 @@ -# Source publication review - -This review prepares a controlled private-to-public transition for `diffexai/diffex`. -It does not change repository visibility or authorize package publication. -CI failures and Dependabot remediation are deferred at the owner's explicit request. -Neither is represented as resolved. -The reviewed source is ready for the controlled transition below, subject to landing the reviewed changes and pausing Actions before the visibility switch. -It is not ready for unrestricted public workflow execution until the public controls are verified. - -## Publication scope - -Making this repository public exposes reachable branches and tags, historical source, issues and pull requests, comments, and retained Actions evidence. -The approved npm payload is a smaller and separate distribution scope. -Source publication includes development records under `docs/release`, agent prompts, evaluation adapters, test fixtures, and native helper source. -Do not rely on `private` package flags or artifact names to keep those materials confidential. -Do not change visibility if any of this material must remain private. - -The current npm policy permits only `@diffexai/diffex`, with bundled runtime workspaces. -Opening the source does not authorize standalone binaries, container publication, or additional package registries. -Historical distribution reviews describe the policy in force when they were written; they are not current claims of access protection after a visibility change. - -## Review evidence - -The review started from source commit `006e464e149f16fd14e36624bd9adf98d0c29e9d` on 2026-10-17. -A fresh remote mirror contains 242 branch refs, nine tag refs, 246 pull-request refs, and 6,797 reachable commits. -Gitleaks 8.30.1 was downloaded from its official release and its archive SHA-256 matched the published checksum: `b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5`. -The scanner used its default rules, full redaction, and ignored inline allow comments. - -Gitleaks reported 6,401 commits scanned and 42 generic-key matches. -The tracked working-tree scan reported nine matches. -Current-tree matches are test idempotency strings and a keyboard binding. -Historical matches additionally include model identifiers, an HTTP beta-feature header, and upstream OAuth client constants. -The historical Google Cloud and Google Antigravity OAuth files are byte-identical to their public Pi upstream files at the same commits. -Those client constants are not evidence of a private Diffex account credential. -The historical compaction transcript fixture also has the same Git blob identity as public upstream: `5e744e3d4b69b43789776719dd900af043fd8a8d`. - -The GitHub discussion inventory contains 239 issues/pull requests, 26 issue comments, 48 pull-request review comments, and 83 review bodies retrieved across all 235 PRs. -No GitHub release records or gist links were present in that inventory. -Wiki, Pages, and Discussions are disabled. -Four linked attachments were downloaded with authenticated access and visually inspected: a CLI schema-error screenshot and three demonstration UI screenshots, without visible credentials or personal data. -The four tracked coding-agent documentation images were also visually inspected; they contain demonstration UI or artwork, without visible credentials. - -All 501 inventoried artifacts and 2,021 workflow log archives were downloaded and scanned with nested-archive traversal enabled to depth eight. -Two older runs, `32985640027` and `32985547564`, returned HTTP 404 for their logs and could not be reviewed. -The combined scan also included issue/PR text, review bodies, and Git commit metadata and scanned approximately 778.55 MB of content. -It produced 272 matches: 252 generic-key matches in archived test strings, keyboard bindings, and model-file manifests, plus 20 commit SHA strings misidentified as Sourcegraph tokens. -The flagged token-plan manifest entries were verified against the SHA-256 of the embedded model files. -An additional pass unpacked 1,005 Docker build-record members and scanned approximately 119.54 MB without matches. -No private Diffex account credential was confirmed in these results. -This conclusion does not mean every scanner match was a placeholder: the historical public upstream OAuth client constants are real client identifiers already shipped upstream. -The closing remote-history refresh produced the same 42 findings and no new refs or commits. -A subsequent inventory found one additional model-catalog workflow run, `39061334078`, which was skipped without executing jobs; the artifact count remained 501. - -These checks are a targeted disclosure review, not a proof that no secret, personal data, or application vulnerability exists. -Expired or deleted content and third-party systems outside the repository cannot be certified by this review. -New commits, comments, artifacts, and runs after the inventory require a delta review before the visibility change. - -## Source notices - -The root `LICENSE` provides the MIT terms for Diffex and preserves the upstream MIT terms and copyright for Pi-derived portions. -The vendored HTML-exporter libraries retain their bundle headers. -Their complete upstream texts are tracked at `legal/upstream/marked-18.0.5.LICENSE` and `legal/upstream/highlight-11.9.0.LICENSE`. -`legal/upstream/sources.json` records the source revisions. -Do not substitute the project license for those third-party terms. -The review verifies the notices present in the repository; it cannot establish ownership of unpublished contributions or third-party brand permissions on the owner's behalf. - -## Workflow review - -The pull-request-target gate uses the GitHub API and does not check out or execute pull-request code. -The contributor-approval workflow checks out the default branch and verifies maintainer permissions before changing its allowlist. -Issue-label workflows use static scripts and API arguments rather than shell interpolation of issue bodies. -Pull-request build and acceptance jobs run on GitHub-hosted runners without publication secrets. -Repository token defaults are read-only and Actions cannot approve pull requests. -Issue analysis remains disabled in GitHub and inert in the merged security workflow. -Model publication requires a successful same-repository push to main, or a main-only scheduled/manual publication. -Release and benchmark credentials remain environment-scoped and require the deployment restrictions below before Actions is resumed publicly. - -## Controlled visibility transition - -1. Land the reviewed security and notice changes on the default branch. - Resolve the merge procedure explicitly if the deferred CI failure prevents a protected merge; do not silently bypass protection. -2. Pause repository Actions and ensure no credential-bearing runs remain active. - Keep issue analysis disabled independently of the repository-wide pause. -3. Record the final source SHA and complete the delta disclosure review. - Preserve any evidence required for release recovery before deleting retained artifacts or runs. - Clear the four inventoried Actions caches, including npm caches and CodeQL databases, so pre-publication cache contents are not exposed to public workflow consumers. -4. Change visibility only after owner approval of the disclosure scope. -5. Before resuming Actions, verify or recreate main protection, release-tag protection, deployment policies, and scanning controls. -6. Resume only the reviewed workflows after the controls below pass readback. - Do not resume standalone/container publication or issue analysis as part of this transition. - -The latest private-repository API readback returned a plan restriction for rulesets and reported secret scanning disabled. -Earlier successful settings writes are therefore not sufficient evidence that protections remain enforced. -Public visibility should unlock the relevant controls; every control must be read back after the change. - -## Required public controls - -| Control | Required configuration | -| --- | --- | -| Main | Require PR approval, dismiss stale approvals, resolve review threads, require CI, and prohibit force pushes/deletion; no routine bypass. | -| Release tags | Restrict creation of `v*` tags to release maintainers; prohibit updates/deletion independently of creation privileges. | -| Model environment | Allow only branch `main`, not a tag named `main`. | -| Release environments | Allow only reviewed `main` recovery runs and `v*` tags; require Pepps233 approval and disallow admin bypass. | -| Benchmark environment | Restrict credential-bearing runs to reviewed main; retain only evidence approved for public disclosure. | -| Fork workflows | Require approval for all outside collaborators; retain read-only default token permissions and prohibit Actions PR approval. | -| Security controls | Enable secret scanning, push protection, Code Security/CodeQL, and private vulnerability reporting; verify actual status. | - -Pepps233 is the only currently observed repository admin. -An admin-role exception for tag creation also grants future admins that capability; use a dedicated release team if that is not intended. -With Pepps233 as the sole deployment reviewer, preventing self-review would block deployments initiated by that same account. -Independent deployment review requires another authorized reviewer or a different initiator. - -## Deferred follow-up - -- Fix CI and triage dependency alerts separately, as requested by the owner. -- Adapt the release command and contributor-approval automation to PR-based changes under main protection. -- Keep issue analysis disabled until credentials, execution, and output publication have independently enforced boundaries. -- Review CodeQL and secret-scanning results after public activation before enabling publication jobs. -- Keep non-source distribution channels deferred until their separate redistribution review is complete. diff --git a/docs/release/version-update-reproduction.md b/docs/release/version-update-reproduction.md deleted file mode 100644 index c4bf42566..000000000 --- a/docs/release/version-update-reproduction.md +++ /dev/null @@ -1,132 +0,0 @@ -# Version discovery and foreign-package update reproduction - -Verified on 2026-09-10 against `chore/luntraa-diffex-rename` revision `ff2250127`, using Node `22.19.0` on macOS arm64. -The checked-out package identifies itself as `@diffexai/diffex@0.2.0`. -The tests read the installed identity from the package manifest so the reproduction also follows a later package rename. - -## Finding - -Interactive startup and `diffex update --self` request `https://pi.dev/api/latest-version`. -A response naming `@foreign-fixture/untrusted-cli` reaches the CLI's real package-command execution boundary and causes an attempted uninstall of the installed package followed by an attempted install of the foreign package. -`--force` also accepts the foreign identity when the response version equals the installed version. -The reproduction captures both commands before execution and simulates successful completion; no package manager runs and no installed software changes. - -The finding and captured commands below are historical evidence from the vulnerable implementation. -The same reproduction was confirmed again before adding the [canonical self-update safeguard](self-update-target.md). -The executable suite now asserts rejection of foreign targets and retains CLI and terminal coverage. -The shared source consumer now uses the Diffex authority as documented in the [consumer migration record](self-update-target.md); deployed client canaries and production activation remain separate validation work. - -## Repeat the reproduction - -Use the repository's installed dependencies, Node `22.19.0` or newer, Python 3, and a POSIX system with PTY support. -The suite explicitly skips Windows; it makes no claim about Windows package-manager execution. -No build, release, npm installation, production credentials, or live endpoint is required. - -From `packages/coding-agent`: - -```sh -node ../../node_modules/vitest/dist/cli.js --run test/version-update-e2e.test.ts test/package-command-paths.test.ts test/version-check.test.ts --maxWorkers=1 --reporter=verbose -``` - -The source entrypoint is `src/cli.ts`, loaded with `tsx` and the repository's TypeScript path mappings. -Manual cases pass `update --self`, optionally followed by `--force`. -Interactive cases pass `--no-session --no-extensions --no-skills` and render the real startup UI in a 100-column, 30-row PTY. -After the welcome header appears, the fixture submits `/name ci-ready` and waits for its rendered confirmation. -The delayed-discovery journey releases its controlled response only after that confirmation proves input works while discovery is pending. -After response parsing or failure and its promise continuations finish, the fixture submits `/name ci-settled`, waits for confirmation, then submits `/exit`. -No model prompt is submitted. -The expected no-model warning reflects the deliberately empty credential configuration. - -Each case gets a fresh temporary home, agent directory, project directory, and event record. -The seven source-CLI launches share one suite-owned directory through `TMPDIR`, `TMP`, and `TEMP` so the installed `tsx` can reuse its compilation cache. -After every launch, the suite verifies that only compiler cache directories remain and that previously cached files were not rewritten. -The shared directory is removed after all children exit, and every complete suite run begins with an empty directory. -Other reproduction callers, including staging canaries, keep isolated temporary-directory defaults. -The child environment is constructed from an allowlist and does not inherit credentials, proxy settings, user resource configuration, or loader options. -Manual cases model a writable global npm installation with a temporary package manifest and an overridden package/runtime path; install-method detection, update planning, command construction, and command dispatch remain real application code. - -The preload imports HTTP configuration before installing the fetch interceptor so CLI initialization preserves the test override. -The interceptor records the actual requested URL, method, and user agent, supplies only the controlled version response, and returns `503` for unrelated background fetches. -A socket guard rejects alternate network access. -Child-process APIs are intercepted before application imports; npm root discovery is simulated, install/uninstall attempts are recorded without execution, and other application subprocesses are blocked. -Only the local `tsx` compiler's esbuild service may execute. - -## Coverage consolidation (2026-09-14) - -The source-CLI boundary now runs seven launches, including four interactive terminals, instead of 40 launches and 15 terminals. -The four terminal journeys cover fresh installation with a canonical newer release, upgrade with legacy telemetry enabled and delayed network failure, current installation with automatic checks disabled and provider attribution enabled, and fresh offline installation. -The upgrade journey supplies a temporary two-release changelog because the repository currently has only one released entry. -Every journey verifies input, normal exit, persisted settings, absence of install reporting, and absence of installation attempts. -The three manual journeys retain exact canonical installation after automatic opt-out, forced foreign-package rejection, and forced offline suppression. -Detailed safety and failure permutations execute through the real package-command handler, while transport redirect, cancellation, and retry tests remain unchanged. - -On macOS arm64 with Node `22.19.0`, the original two E2E files passed 40 tests in 57.88 seconds with one worker. -Three complete runs of the command, version, and consolidated E2E suites each passed 125 tests with one worker and a fresh suite compilation cache: - -| Run | Total | Fresh install PTY | Delayed upgrade PTY | Checks disabled PTY | Offline PTY | -| --- | --- | --- | --- | --- | --- | -| 1 | 13.71 s | 2.485 s | 2.288 s | 2.275 s | 2.280 s | -| 2 | 13.57 s | 2.397 s | 2.295 s | 2.279 s | 2.296 s | -| 3 | 13.59 s | 2.384 s | 2.317 s | 2.280 s | 2.292 s | - -The median complete run was 13.59 seconds, below the 20-second target without a wall-clock assertion. -Every run verified scenario cleanup and unchanged compilation cache files between launches, then removed the shared directory. -A temporary negative check emitted a diagnostic after delayed discovery failed and confirmed that the quiet-startup assertion caught it. -The diagnostic injection was removed before the benchmark runs. -`npm run check` passed, and all retained coverage remains in ordinary CI with no workflow parallelism or release-script changes. - -## Observed requests and commands - -The version request is an unauthenticated `GET` with a user agent beginning `diffex/0.2.0 `. -No request reaches Pi over the network in this test. -The foreign response fixture is: - -```json -{ - "version": "9999.0.0", - "packageName": "@foreign-fixture/untrusted-cli" -} -``` - -The following is a captured command sequence, with the per-test temporary prefix replaced by ``. -These lines are evidence, not commands to run manually. - -```text -npm --prefix uninstall -g @diffexai/diffex -npm --prefix install -g --ignore-scripts --min-release-age=0 @foreign-fixture/untrusted-cli@9999.0.0 -``` - -For `diffex update --self --force`, the fixture supplies `0.2.0`, equal to the current version. -The uninstall attempt is identical and the install target becomes `@foreign-fixture/untrusted-cli@0.2.0`. -The CLI prints its combined `Updating diffex with ...` command and reports success only because the guard emits simulated successful process completion. - -| User path | Controlled condition | Version requests | Observed result | -| --- | --- | --- | --- | -| Interactive startup | Foreign newer release | 1 | Welcome screen, no update notification, no package command | -| Interactive startup | `DIFFEX_OFFLINE=1` | 0 | Welcome screen, no version error or package command | -| Interactive startup | `DIFFEX_SKIP_VERSION_CHECK=1` | 0 | Welcome screen, no version error or package command | -| Interactive startup | Transport failure | 1 | Welcome screen, no retry or visible version error | -| `update --self` | Foreign newer release | 1 | Uninstall and foreign install attempts captured | -| `update --self --force` | Foreign equal-version release | 1 | Uninstall and foreign install attempts captured | -| `update --self` | Automatic opt-out enabled | 1 | Manual uninstall and foreign install attempts still occur | -| `update --self` | Offline | 0 | Exit 1, no package command | -| `update --self --force` | Offline | 0 | Exit 1, no package command | -| `update --self` | Transport failure | 3 | Exit 1 after two retries, no package command | - -Offline manual update prints `Error: Could not determine latest diffex version.` -The controlled transport failure prints `Error: Could not determine latest diffex version: controlled network failure`. -Startup remains silent even for a valid newer response because `DIFFEX_UPDATE_AVAILABLE_NOTIFICATION_ENABLED` is currently `false` in `interactive-mode.ts`; the request still happens. - -## Validation and cleanup - -The original dedicated reproduction passed all 10 cases. -`npm run check` passed, including type checking, dependency checks, and the browser smoke check. -`./test.sh` passed after refreshing local workspace links with `npm install --ignore-scripts --offline` and adding the missing agent `/node` source alias in `vitest.base.ts`. -The checked-in lockfile is unchanged. -The focused server-harness and CLI reproduction run passed 22 tests after the rebase. - -Every interactive case exited through `/exit` with status 0. -Every test removed its temporary directory and asserted that it no longer existed. -The harness enforces a 20-second case deadline and the PTY bridge kills and reaps the CLI on abnormal termination. -No package-manager process, provider call, installation, uninstallation, release publication, or infrastructure mutation occurred in the reproduction. -This establishes source-CLI behavior under controlled responses; it does not verify a published binary, live HTTPS routing, npm availability, or a deployed replacement endpoint. diff --git a/homebrew/diffex.rb.template b/homebrew/diffex.rb.template new file mode 100644 index 000000000..54d52da92 --- /dev/null +++ b/homebrew/diffex.rb.template @@ -0,0 +1,89 @@ +require "json" + +class Diffex < Formula + desc "Terminal coding agent" + homepage "https://diffex.ai/" + version "@@VERSION@@" + license "MIT" + + on_macos do + on_arm do + url "@@DARWIN_ARM64_URL@@" + sha256 "@@DARWIN_ARM64_SHA256@@" + end + on_intel do + url "@@DARWIN_X64_URL@@" + sha256 "@@DARWIN_X64_SHA256@@" + end + + depends_on macos: :sequoia + end + + on_linux do + on_arm do + url "@@LINUX_ARM64_URL@@" + sha256 "@@LINUX_ARM64_SHA256@@" + end + on_intel do + url "@@LINUX_X64_URL@@" + sha256 "@@LINUX_X64_SHA256@@" + end + end + + depends_on arch: :arm64 if Hardware::CPU.arm? + depends_on arch: :x86_64 if Hardware::CPU.intel? + depends_on "fd" + depends_on "git" + depends_on "ripgrep" + + def install + odie "Diffex requires an arm64 or x86_64 CPU" unless Hardware::CPU.arm? || Hardware::CPU.intel? + if OS.linux? + ldd = Utils.safe_popen_read("ldd", "--version") + odie "Diffex requires glibc (Ubuntu 24.04 baseline), not musl" if ldd.downcase.include?("musl") + match = ldd.lines.first.to_s.match(/(\d+\.\d+)\s*$/) + unless match && Gem::Version.new(match[1]) >= Gem::Version.new("2.39") + odie "Diffex requires glibc 2.39 or newer (Ubuntu 24.04 baseline)" + end + end + # Homebrew strips the sole archive root directory before invoking install. + # Preserve the entire payload, including dotfiles; the marker is a sibling. + libexec.install buildpath => "diffex" + (libexec/".diffex-homebrew.json").write JSON.generate({ + schemaVersion: 1, + package: "@diffexai/diffex", + formula: "diffexai/tap/diffex", + }) + "\n" + bin.install_symlink libexec/"diffex/bin/diffex" + end + + test do + ENV["DIFFEX_OFFLINE"] = "1" + ENV["DIFFEX_SKIP_UPDATE_CHECK"] = "1" + ENV["DIFFEX_CODING_AGENT_DIR"] = (testpath/"user-data").to_s + ENV["XDG_CONFIG_HOME"] = (testpath/"config").to_s + ENV["XDG_CACHE_HOME"] = (testpath/"cache").to_s + ENV["XDG_DATA_HOME"] = (testpath/"data").to_s + (testpath/"user-data").mkpath + (testpath/".diffex").mkpath + (testpath/"user-data/retained.txt").write "retain user data\n" + (testpath/".diffex/retained.txt").write "retain project data\n" + snapshot = lambda do + Dir.glob("**/*", File::FNM_DOTMATCH).sort.filter_map do |path| + next if [".", ".."].include?(File.basename(path)) + + [path, File.directory?(path) ? nil : File.binread(path)] + end + end + assert_equal version.to_s, shell_output("#{bin}/diffex --version").strip + assert_match "Diffex", shell_output("#{bin}/diffex --help") + assert_match(/\S/, shell_output("#{bin}/diffex --list-models")) + before = snapshot.call + assert_match "brew upgrade diffexai/tap/diffex", shell_output("#{bin}/diffex update --self") + assert_equal before, snapshot.call, "Ownership-denied self update must not mutate user or project data" + assert_equal({ "schemaVersion" => 1, "package" => "@diffexai/diffex", "formula" => "diffexai/tap/diffex" }, + JSON.parse((libexec/".diffex-homebrew.json").read)) + refute_path_exists libexec/"diffex/.diffex-homebrew.json" + assert_equal ["diffex"], bin.children.map { |path| path.basename.to_s } + end +end diff --git a/homebrew/tap/.github/workflows/test.yml b/homebrew/tap/.github/workflows/test.yml new file mode 100644 index 000000000..ff3d78390 --- /dev/null +++ b/homebrew/tap/.github/workflows/test.yml @@ -0,0 +1,65 @@ +name: Tap checks + +on: + pull_request: + paths: + - Formula/diffex.rb + - homebrew-manifest.json + - test/lifecycle.rb + - .github/workflows/test.yml + push: + branches: [main] + +permissions: + contents: read + +jobs: + native: + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + arch: arm64 + - runner: macos-15-intel + arch: x86_64 + - runner: ubuntu-24.04-arm + arch: aarch64 + - runner: ubuntu-24.04 + arch: x86_64 + runs-on: ${{ matrix.runner }} + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_ANALYTICS: "1" + HOMEBREW_NO_INSTALL_CLEANUP: "1" + DIFFEX_OFFLINE: "1" + DIFFEX_SKIP_UPDATE_CHECK: "1" + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + - name: Configure native Homebrew + shell: bash + run: | + set -euo pipefail + test "$(uname -m)" = '${{ matrix.arch }}' + if [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + fi + command -v brew + echo "$(brew --prefix)/bin" >> "$GITHUB_PATH" + tap="$(brew --repository)/Library/Taps/diffexai/homebrew-tap" + mkdir -p "$(dirname "$tap")" + test ! -e "$tap" + ln -s "$GITHUB_WORKSPACE" "$tap" + - name: Formula style and offline audit + run: | + brew style diffexai/tap/diffex + brew audit --strict diffexai/tap/diffex + - name: Install and test the approved public runtime + run: | + brew install --formula diffexai/tap/diffex + brew test diffexai/tap/diffex + brew uninstall --formula diffexai/tap/diffex + - name: Native install, upgrade, and uninstall fixtures + run: brew ruby test/lifecycle.rb diff --git a/homebrew/tap/test/lifecycle.rb b/homebrew/tap/test/lifecycle.rb new file mode 100644 index 000000000..952082575 --- /dev/null +++ b/homebrew/tap/test/lifecycle.rb @@ -0,0 +1,88 @@ +# Native, network-free lifecycle fixture. Run with `brew ruby test/lifecycle.rb`. +# This exercises Homebrew mechanics, not the real runtime; the workflow also +# installs and tests the approved public formula separately. +require "digest" +require "fileutils" +require "json" +require "open3" +require "tmpdir" + +FORMULA = "diffex-fixture/tap/diffex".freeze + +def run(*command) + output, status = Open3.capture2e(*command) + raise "#{command.join(" ")} failed:\n#{output}" unless status.success? + + output +end + +def check(value, message) + raise message unless value +end + +Dir.mktmpdir("diffex-brew-lifecycle-") do |temporary| + repository = run("brew", "--repository").strip + tap = File.join(repository, "Library/Taps/diffex-fixture/homebrew-tap") + check(!File.exist?(tap), "Refusing to overwrite an existing fixture tap") + source = File.read(File.expand_path("../Formula/diffex.rb", __dir__)) + FileUtils.mkdir_p(File.join(tap, "Formula")) + run("git", "-C", tap, "init", "-q") + retained = File.join(temporary, "user-data/retained.txt") + FileUtils.mkdir_p(File.dirname(retained)) + File.write(retained, "retain user data\n") + ENV["DIFFEX_CODING_AGENT_DIR"] = File.dirname(retained) + ENV["DIFFEX_OFFLINE"] = "1" + installed = false + begin + ["0.0.1", "0.0.2"].each_with_index do |version, index| + stage = File.join(temporary, version) + payload = File.join(stage, "diffex") + FileUtils.mkdir_p(File.join(payload, "bin")) + FileUtils.mkdir_p(File.join(payload, "runtime/bin")) + File.write(File.join(payload, "bin/diffex"), <<~SH) + #!/bin/sh + case "$*" in + --version) printf '%s\\n' '#{version}' ;; + --help) printf '%s\\n' 'Diffex fixture' ;; + --list-models) printf '%s\\n' 'fixture offline model' ;; + 'update --self') printf '%s\\n' 'brew upgrade diffexai/tap/diffex' ;; + *) exit 1 ;; + esac + SH + FileUtils.chmod(0755, File.join(payload, "bin/diffex")) + File.write(File.join(payload, "runtime/bin/node"), "fixture bundled runtime\n") + File.write(File.join(payload, ".retained-payload"), "fixture hidden file\n") + File.write(File.join(payload, "LICENSE"), "fixture license\n") + archive = File.join(temporary, "fixture-#{version}.tar.gz") + run("tar", "-czf", archive, "-C", stage, "diffex") + digest = Digest::SHA256.file(archive).hexdigest + formula = source.gsub(/^ version ".*"$/, " version \"#{version}\"") + .gsub(/^ url ".*"$/, " url \"file://#{archive}\"") + .gsub(/^ sha256 ".*"$/, " sha256 \"#{digest}\"") + File.write(File.join(tap, "Formula/diffex.rb"), formula) + run("brew", index.zero? ? "install" : "upgrade", "--formula", FORMULA) + installed = true + prefix = run("brew", "--prefix", FORMULA).strip + check(run(File.join(prefix, "bin/diffex"), "--version").strip == version, "Wrong installed fixture version") + check(File.symlink?(File.join(prefix, "bin/diffex")), "CLI must be a symlink") + check(Dir.children(File.join(prefix, "bin")) == ["diffex"], "Only diffex may be exposed") + marker = JSON.parse(File.read(File.join(prefix, "libexec/.diffex-homebrew.json"))) + check(marker == { "schemaVersion" => 1, "package" => "@diffexai/diffex", "formula" => "diffexai/tap/diffex" }, "Wrong ownership marker") + check(!File.exist?(File.join(prefix, "libexec/diffex/.diffex-homebrew.json")), "Ownership marker must remain outside payload") + Dir.glob(File.join(payload, "**/*"), File::FNM_DOTMATCH).select { |path| File.file?(path) }.each do |path| + target = path.sub(payload, File.join(prefix, "libexec/diffex")) + check(File.binread(target) == File.binread(path), "Payload bytes changed: #{path}") + check(File.stat(target).mode == File.stat(path).mode, "Payload mode changed: #{path}") + end + run("brew", "test", FORMULA) + check(File.read(retained) == "retain user data\n", "User data changed") + end + run("brew", "uninstall", "--formula", FORMULA) + installed = false + check(!File.exist?(File.join(run("brew", "--prefix").strip, "bin/diffex")), "CLI symlink survived uninstall") + check(File.read(retained) == "retain user data\n", "Uninstall removed user data") + ensure + run("brew", "uninstall", "--formula", FORMULA) if installed + FileUtils.rm_rf(tap) + end +end diff --git a/identity-residual-allowlist.md b/identity-residual-allowlist.md deleted file mode 100644 index c3d9099fe..000000000 --- a/identity-residual-allowlist.md +++ /dev/null @@ -1,47 +0,0 @@ -# Reviewed runtime identity residuals - -Review date: 2026-10-27. -This inventory reflects the current Diffex source, superseding the 2026-09-11 integration approvals. -No service, OAuth registration, application attribution, security/staff, Gondolin, disabled-code, or legacy package alias exceptions remain. - -`scripts/runtime-identity-allowlist.json` is the executable review record. -It binds every occurrence to an exact file and full source line, including duplicate occurrences on the same line. -The checker rejects new occurrences, altered contexts, stale entries, and categories other than approved integrations or legal notices. -Run `npm run check:identities`; the root quality gate runs it automatically. -The full source context in the JSON record is authoritative when line numbers move. - -| Source location | Identity matches | Category | Reviewed reason | -| --- | --- | --- | --- | -| `packages/coding-agent/examples/extensions/doom-overlay/README.md` | `pi-doom, pi-doom` | legal notice | Preserves the original integration credit for the redistributed DOOM overlay source. | - -The only executable allowlist entry is the source credit above, with two matches on one line. -Provider attribution uses Diffex defaults; see [provider validation](docs/release/provider-branding-validation.md). -ChatGPT uses Diffex account registration and the public model and inference APIs; see [provider setup](packages/coding-agent/docs/providers.md#chatgpt). -The inherited ChatGPT registration requires a new sign-in. -Radius is removed and prohibited in every environment; see [migration and validation](docs/release/radius-removal.md). -Model catalogs use the Diffex-owned API described in [the catalog contract](docs/model-catalog.md); the former catalog exceptions are removed. -No historical approval authorizes install reporting, upstream update authority, or service traffic. -The source check covers tracked and unignored package source, examples, configuration, workflow files, operational extensions, dependency metadata, and root identity documents. -Tests and their fixtures describe rejected historical inputs and are not shipped runtime configuration. -Released `CHANGELOG.md` records remain untouched under repository policy; their historical package and upstream issue references are not current product identities or runtime permissions. -The handoff and review documents discuss historical names without authorizing them in executable code. - -## Historical evidence and external references - -The release records [candidate-artifacts.json](docs/release/candidate-artifacts.json), [candidate-identity-review.json](docs/release/candidate-identity-review.json), and [candidate-rehearsal.json](docs/release/candidate-rehearsal.json) preserve the exact artifacts, source contexts, URLs, and headers reviewed at their pinned revisions. -Their approval labels describe those historical reviews, not current permissions or acceptance of those bytes as a release candidate. -Do not rewrite their recorded identities or checksums to match current source. -The [candidate handoff](docs/release/candidate-handoff.md) and [rehearsal](docs/release/candidate-rehearsal.md) distinguish that evidence from current policy. - -The coding-agent README's MCP article and Anthropic implementation comments' research links are external technical references. -They do not name the application or configure service defaults. -Historical package names and URLs in fixtures and security rejection patterns remain necessary to verify that removed identities cannot return. -`@mariozechner/clipboard` is an external dependency whose accurate package name remains in dependency metadata, imports, and build scripts. -None of these references adds an executable allowlist exception. - -`LICENSE` retains the Pi Agent Harness MIT notice, Mario Zechner copyright, and Luntraa, Inc. copyright. -The root README and DOOM overlay credit preserve source attribution for redistributed upstream code. -No license or copyright ownership was changed. -Distribution-specific notice completeness is separate release work. - -The candidate handoff records historical artifact checksums, validation commands, deployment records, and the owner-requested live-provider skip. diff --git a/legal/upstream/bun-1.3.14.LICENSE b/legal/upstream/bun-1.3.14.LICENSE deleted file mode 100644 index 81069ee8d..000000000 --- a/legal/upstream/bun-1.3.14.LICENSE +++ /dev/null @@ -1,82 +0,0 @@ -Bun itself is MIT-licensed. - -## JavaScriptCore - -Bun statically links JavaScriptCore (and WebKit) which is LGPL-2 licensed. WebCore files from WebKit are also licensed under LGPL2. Per LGPL2: - -> (1) If you statically link against an LGPL’d library, you must also provide your application in an object (not necessarily source) format, so that a user has the opportunity to modify the library and relink the application. - -You can find the patched version of WebKit used by Bun here: . If you would like to relink Bun with changes: - -- `git submodule update --init --recursive` -- `make jsc` -- `zig build` - -This compiles JavaScriptCore, compiles Bun’s `.cpp` bindings for JavaScriptCore (which are the object files using JavaScriptCore) and outputs a new `bun` binary with your changes. - -## Linked libraries - -Bun statically links these libraries: - -| Library | License | -|---------|---------| -| [`boringssl`](https://boringssl.googlesource.com/boringssl/) | [several licenses](https://boringssl.googlesource.com/boringssl/+/refs/heads/master/LICENSE) | -| [`brotli`](https://github.com/google/brotli) | MIT | -| [`libarchive`](https://github.com/libarchive/libarchive) | [several licenses](https://github.com/libarchive/libarchive/blob/master/COPYING) | -| [`lol-html`](https://github.com/cloudflare/lol-html/tree/master/c-api) | BSD 3-Clause | -| [`ls-hpack`](https://github.com/litespeedtech/ls-hpack) | MIT | -| [`ls-qpack`](https://github.com/litespeedtech/ls-qpack) | MIT | -| [`lsquic`](https://github.com/litespeedtech/lsquic) | MIT (portions derived from [Chromium proto-quic](https://github.com/litespeedtech/lsquic/blob/master/LICENSE.chrome), BSD 3-Clause) | -| [`mimalloc`](https://github.com/microsoft/mimalloc) | MIT | -| [`picohttp`](https://github.com/h2o/picohttpparser) | dual-licensed under the Perl License or the MIT License | -| [`zstd`](https://github.com/facebook/zstd) | dual-licensed under the BSD License or GPLv2 license | -| [`simdutf`](https://github.com/simdutf/simdutf) | Apache 2.0 | -| [`tinycc`](https://github.com/tinycc/tinycc) | LGPL v2.1 | -| [`uSockets`](https://github.com/uNetworking/uSockets) | Apache 2.0 | -| [`zlib-ng`](https://github.com/zlib-ng/zlib-ng) | zlib | -| [`c-ares`](https://github.com/c-ares/c-ares) | MIT licensed | -| [`libicu`](https://github.com/unicode-org/icu) 72 | [license here](https://github.com/unicode-org/icu/blob/main/icu4c/LICENSE) | -| [`libbase64`](https://github.com/aklomp/base64/blob/master/LICENSE) | BSD 2-Clause | -| [`libuv`](https://github.com/libuv/libuv) (on Windows) | MIT | -| [`libdeflate`](https://github.com/ebiggers/libdeflate) | MIT | -| [`libjpeg-turbo`](https://github.com/libjpeg-turbo/libjpeg-turbo) | [BSD 3-Clause / IJG / zlib](https://github.com/libjpeg-turbo/libjpeg-turbo/blob/main/LICENSE.md) | -| [`libspng`](https://github.com/randy408/libspng) | BSD 2-Clause | -| [`libwebp`](https://github.com/webmproject/libwebp) | BSD 3-Clause | -| [`highway`](https://github.com/google/highway) | Apache 2.0 | -| [`uucode`](https://github.com/jacobsandlund/uucode) | MIT | -| A fork of [`uWebsockets`](https://github.com/jarred-sumner/uwebsockets) | Apache 2.0 licensed | -| Parts of [Tigerbeetle's IO code](https://github.com/tigerbeetle/tigerbeetle/blob/532c8b70b9142c17e07737ab6d3da68d7500cbca/src/io/windows.zig#L1) | Apache 2.0 licensed | -| `__cxa_thread_atexit` fallback from [LLVM libc++abi](https://github.com/llvm/llvm-project/blob/llvmorg-19.1.0/libcxxabi/src/cxa_thread_atexit.cpp) | Apache 2.0 with LLVM exception | - -## Polyfills - -For compatibility reasons, the following packages are embedded into Bun's binary and injected if imported. - -| Package | License | -|---------|---------| -| [`assert`](https://npmjs.com/package/assert) | MIT | -| [`browserify-zlib`](https://npmjs.com/package/browserify-zlib) | MIT | -| [`buffer`](https://npmjs.com/package/buffer) | MIT | -| [`constants-browserify`](https://npmjs.com/package/constants-browserify) | MIT | -| [`crypto-browserify`](https://npmjs.com/package/crypto-browserify) | MIT | -| [`domain-browser`](https://npmjs.com/package/domain-browser) | MIT | -| [`events`](https://npmjs.com/package/events) | MIT | -| [`https-browserify`](https://npmjs.com/package/https-browserify) | MIT | -| [`os-browserify`](https://npmjs.com/package/os-browserify) | MIT | -| [`path-browserify`](https://npmjs.com/package/path-browserify) | MIT | -| [`process`](https://npmjs.com/package/process) | MIT | -| [`punycode`](https://npmjs.com/package/punycode) | MIT | -| [`querystring-es3`](https://npmjs.com/package/querystring-es3) | MIT | -| [`stream-browserify`](https://npmjs.com/package/stream-browserify) | MIT | -| [`stream-http`](https://npmjs.com/package/stream-http) | MIT | -| [`string_decoder`](https://npmjs.com/package/string_decoder) | MIT | -| [`timers-browserify`](https://npmjs.com/package/timers-browserify) | MIT | -| [`tty-browserify`](https://npmjs.com/package/tty-browserify) | MIT | -| [`url`](https://npmjs.com/package/url) | MIT | -| [`util`](https://npmjs.com/package/util) | MIT | -| [`vm-browserify`](https://npmjs.com/package/vm-browserify) | MIT | - -## Additional credits - -- Bun's JS transpiler, CSS lexer, and Node.js module resolver source code is a Zig port of [@evanw](https://github.com/evanw)’s [esbuild](https://github.com/evanw/esbuild) project. -- Credit to [@kipply](https://github.com/kipply) for the name "Bun"! \ No newline at end of file diff --git a/legal/upstream/data-uri-to-buffer-4.0.1.LICENSE b/legal/upstream/data-uri-to-buffer-4.0.1.LICENSE deleted file mode 100644 index 237bedfe2..000000000 --- a/legal/upstream/data-uri-to-buffer-4.0.1.LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -(The MIT License) - -Copyright (c) 2014 Nathan Rajlich <nathan@tootallnate.net> - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/legal/upstream/entities-2.1.0.LICENSE b/legal/upstream/entities-2.1.0.LICENSE deleted file mode 100644 index 561468f11..000000000 --- a/legal/upstream/entities-2.1.0.LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Nodable - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/legal/upstream/sources.json b/legal/upstream/sources.json deleted file mode 100644 index 9483c72a0..000000000 --- a/legal/upstream/sources.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "marked-18.0.5.LICENSE": [ - "markedjs/marked", - "4063c638cb621c09091d41b26f323ff074416bb9", - "LICENSE" - ], - "highlight-11.9.0.LICENSE": [ - "highlightjs/highlight.js", - "f47103d4f1ac1592c56904574d1fbf5bf2475605", - "LICENSE" - ], - "entities-2.1.0.LICENSE": [ - "nodable/val-parsers", - "f1c61a65e7b967c17b13822ef71e91bd25f17ce2", - "LICENSE" - ], - "data-uri-to-buffer-4.0.1.LICENSE": [ - "TooTallNate/node-data-uri-to-buffer", - "85cd8c854aefbf1bb636789d80364cfac8ea1583", - "README.md" - ], - "xml-naming-0.1.0.LICENSE": [ - "NaturalIntelligence/xml-naming", - "0330d0cfa57ee483a834c474b2d0a4f0449ee81d", - "LICENSE" - ], - "bun-1.3.14.LICENSE": [ - "oven-sh/bun", - "bun-v1.3.14", - "LICENSE.md" - ] -} diff --git a/legal/upstream/xml-naming-0.1.0.LICENSE b/legal/upstream/xml-naming-0.1.0.LICENSE deleted file mode 100644 index 0311839a0..000000000 --- a/legal/upstream/xml-naming-0.1.0.LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Natural Intelligence - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/ai/README.md b/packages/ai/README.md index 10862eda2..6b4e8ca4a 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -241,7 +241,7 @@ The Anthropic, Google GenAI, Mistral, and OpenAI SDK updates leave existing prov - **Anthropic**: API version `2026-10-21` no longer accepts `temperature`, `top_p`, or `top_k`. The adapter omits these sampling parameters; the public unified `temperature` option is now ignored for Anthropic. - **Google Gemini and Vertex AI**: The adapters explicitly set `automaticContinuation: false`. A `CONTINUATION` finish reason ends the request rather than triggering hidden SDK follow-up requests; applications control any subsequent request. -See [Stop Reasons](#stop-reasons) for limit/continuation mappings and [dependency maintenance](../../docs/dependency-maintenance.md) for update procedures and compatibility notes. +See [Stop Reasons](#stop-reasons) for limit/continuation mappings and [contributor guidelines](../../CONTRIBUTING.md#engineering-expectations) for dependency requirements. ### Provider Factories diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cd8528a00..9c895190d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,17 +2,64 @@ ## [Unreleased] +## [0.4.1] - 2026-10-29 + +### New Features + +- Added clearer, safer self-updates with terminal activity feedback, actionable recovery guidance, and npm target-archive prefetch before installation replacement. + See [Upgrade](docs/installation.md#upgrade) ([#298](https://github.com/diffexai/diffex/pull/298)). +- Made npm installations leaner by bundling the Mistral provider's required JavaScript, reducing external dependencies while preserving provider behavior. + See [Installation](docs/installation.md#install-and-verify) ([#300](https://github.com/diffexai/diffex/pull/300)). +- Added account-aware ChatGPT defaults that respect model availability and preserve usable saved choices across startup, login, account switching, and session restoration. + See [ChatGPT setup](docs/providers.md#chatgpt) ([#303](https://github.com/diffexai/diffex/pull/303)). + +### Breaking Changes + +- Previously released: `AgentSession.dispose()` returns `Promise`. SDK consumers must await disposal before releasing process resources or deleting a session workspace. + See [AgentSession](docs/sdk.md#agentsession) ([#20](https://github.com/diffexai/diffex/pull/20)). +- Previously released: changed `BuildSystemPromptOptions.promptGuidelines` from a flat array to guideline arrays keyed by tool name. Update direct prompt-builder integrations to pass an object such as `{ my_tool: ["Guidance"] }`; individual tool definitions still use arrays. + See [Custom tools](docs/extensions.md#custom-tools) ([#232](https://github.com/diffexai/diffex/pull/232)). +- Previously released: replaced the interactive `/new` and `/quit` commands with `/clear` and `/exit`. Update command-driven workflows ([#10](https://github.com/diffexai/diffex/pull/10)). +- Previously released: renamed `PI_CACHE_RETENTION` and `PI_OAUTH_CALLBACK_HOST` to `DIFFEX_CACHE_RETENTION` and `DIFFEX_OAUTH_CALLBACK_HOST`. Update provider configuration and OAuth callback-host overrides ([#2](https://github.com/diffexai/diffex/pull/2)). + +### Added + +- Previously released: added durable, branch-aware subagent restoration and child-to-parent escalation coordination ([#114](https://github.com/diffexai/diffex/pull/114) by [@Issacboy123](https://github.com/Issacboy123), [#115](https://github.com/diffexai/diffex/pull/115) by [@Issacboy123](https://github.com/Issacboy123), [#117](https://github.com/diffexai/diffex/pull/117) by [@Issacboy123](https://github.com/Issacboy123), [#119](https://github.com/diffexai/diffex/pull/119) by [@Issacboy123](https://github.com/Issacboy123), [#120](https://github.com/diffexai/diffex/pull/120) by [@Issacboy123](https://github.com/Issacboy123), [#121](https://github.com/diffexai/diffex/pull/121) by [@Issacboy123](https://github.com/Issacboy123)). +- Previously released: added SDK `PromptOptions.signal` support to cancel prompt preflight before agent start while preserving session state. + See [Prompting and message queueing](docs/sdk.md#prompting-and-message-queueing) ([#143](https://github.com/diffexai/diffex/pull/143) by [@Issacboy123](https://github.com/Issacboy123)). +- Added post-merge installed-CLI and terminal behavior checks in isolated Daytona sandboxes, with sanitized diagnostic artifacts for regression investigation. + See [Daytona behavior checks](../../docs/daytona-behaviors.md) ([#295](https://github.com/diffexai/diffex/pull/295)). + ### Changed -- Added a compact terminal activity display for npm and pnpm self-updates, while preserving package-manager warnings, failure diagnostics, and redirected logs. -- Added pnpm installation, upgrade, integrity, and removal coverage to release acceptance alongside npm and Bun. -- Bundle the Mistral provider's required JavaScript into npm releases, reducing the external lockfile from 228 to 140 package entries while preserving provider behavior, optional tracing integrations, and complete dependency license attribution. -- Expanded the published npm README with requirements, npm/pnpm commands, verification, login, and update instructions. +- Previously released: deleting a parent session first cleans up its owned child-session files, retains the parent if child cleanup fails, and reports partial failures ([#118](https://github.com/diffexai/diffex/pull/118) by [@Issacboy123](https://github.com/Issacboy123)). +- Previously released: `/share` returns the secret GitHub Gist's direct URL instead of depending on the upstream share viewer, while preserving custom viewer configuration ([#3](https://github.com/diffexai/diffex/pull/3) by [@jefft72](https://github.com/jefft72)). +- Added a compact terminal activity display for npm and pnpm self-updates, while preserving package-manager warnings, failure diagnostics, and redirected logs ([#298](https://github.com/diffexai/diffex/pull/298)). +- Added pnpm installation, upgrade, integrity, and removal coverage to release acceptance alongside npm and Bun ([#299](https://github.com/diffexai/diffex/pull/299)). +- Bundle the Mistral provider's required JavaScript into npm releases, reducing the external lockfile from 228 to 140 package entries while preserving provider behavior, optional tracing integrations, and complete dependency license attribution ([#300](https://github.com/diffexai/diffex/pull/300)). +- Expanded the published npm README with requirements, npm/pnpm commands, verification, login, and update instructions ([#300](https://github.com/diffexai/diffex/pull/300)). +- Made ordinary `npm i -g @diffexai/diffex` the default installation command, with reviewed dependency lifecycle hooks; installation with `--ignore-scripts` remains supported and self-updates continue to disable scripts. + See [Installation](docs/installation.md#install-and-verify) ([#301](https://github.com/diffexai/diffex/pull/301)). ### Fixed -- Added actionable installation recovery guidance for permissions, unsupported Node versions, interrupted updates, registry failures, and integrity errors without deleting user data or blindly pruning caches. -- Check the CLI's Node.js requirement before initializing application dependencies. +- Previously released: fixed subagent cancellation, inherited hook-policy enforcement, selected-branch spawn provenance, and prompt URL metadata compatibility ([#141](https://github.com/diffexai/diffex/pull/141) by [@Issacboy123](https://github.com/Issacboy123), [#144](https://github.com/diffexai/diffex/pull/144) by [@Issacboy123](https://github.com/Issacboy123), [#145](https://github.com/diffexai/diffex/pull/145) by [@Issacboy123](https://github.com/Issacboy123), [#160](https://github.com/diffexai/diffex/pull/160) by [@Issacboy123](https://github.com/Issacboy123)). +- Previously released: kept Workers AI models discoverable through Cloudflare AI Gateway when the upstream catalog omits duplicate gateway entries ([#111](https://github.com/diffexai/diffex/pull/111)). +- Previously released: fixed cancelled authentication and lazy provider setup being reported as errors instead of aborted requests. +- Previously released: preserved Kimi Coding models after the upstream catalog source key changed. +- Previously released: patched the sandbox extension example's transitive `node-forge` dependency to 1.4.2 to reject malformed RSA signature algorithm structures. +- Previously released: enabled interactive update-available notifications when the release check finds a newer version. +- Added actionable installation recovery guidance for permissions, unsupported Node versions, interrupted updates, registry failures, and integrity errors without deleting user data or blindly pruning caches ([#298](https://github.com/diffexai/diffex/pull/298)). +- Check the CLI's Node.js requirement before initializing application dependencies ([#298](https://github.com/diffexai/diffex/pull/298)). +- Prefetch the verified target npm package archive before replacing the installed CLI, so a failed archive download does not start installation replacement ([#298](https://github.com/diffexai/diffex/pull/298)). +- Fixed ChatGPT compaction failures by omitting unsupported explicit prompt-cache options from both freshly discovered and cached account models ([#302](https://github.com/diffexai/diffex/pull/302)). +- Fixed ChatGPT model selection at startup, login, account switching, and session restoration to use the account's available catalog while preserving available saved and custom choices. Unavailable saved models fall back within the same provider before considering another provider, without rewriting saved defaults. + See [ChatGPT setup](docs/providers.md#chatgpt) ([#303](https://github.com/diffexai/diffex/pull/303)). + +### Removed + +- Previously released: disabled `/cl` prompt-template discovery and execution in production installations; changelog maintenance remains available in development checkouts ([#216](https://github.com/diffexai/diffex/pull/216)). +- Removed the standalone root benchmark project and its Terminal-Bench workflow ([#294](https://github.com/diffexai/diffex/pull/294)). ## [0.4.0] - 2026-10-27 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index fee845b7e..b07dcc73b 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -1,6 +1,6 @@

- Diffex wordmark + Diffex wordmark

diff --git a/packages/coding-agent/docs/development.md b/packages/coding-agent/docs/development.md index c37db56a6..a02048554 100644 --- a/packages/coding-agent/docs/development.md +++ b/packages/coding-agent/docs/development.md @@ -14,7 +14,7 @@ npm run build Run from source: ```bash -/path/to/diffex/diffex-test.sh +/path/to/diffex/scripts/diffex-test.sh ``` The script can be run from any directory. Diffex keeps the caller's current working directory. @@ -55,7 +55,7 @@ Never use `__dirname` directly for package assets. ## Testing ```bash -./test.sh # Run non-E2E tests with credentials isolated +./scripts/test.sh # Run non-E2E tests with credentials isolated # From the relevant package directory: node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts @@ -63,7 +63,7 @@ node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts Do not run the full Vitest suite directly: endpoint or authentication environment variables can activate provider E2E tests. -See [dependency maintenance](../../../docs/dependency-maintenance.md) for dependency update constraints and validation. +See [contributor guidelines](../../../CONTRIBUTING.md#engineering-expectations) for dependency update constraints and validation. ## Project Structure diff --git a/packages/coding-agent/docs/installation.md b/packages/coding-agent/docs/installation.md index 1c93e107c..3e8103197 100644 --- a/packages/coding-agent/docs/installation.md +++ b/packages/coding-agent/docs/installation.md @@ -2,7 +2,8 @@ Diffex is distributed through npmjs.org as `@diffexai/diffex`. Use Node.js 22.19.0 or newer and npm or pnpm on macOS or Linux, on arm64 or x64. -Windows, Android/Termux, standalone binaries, and additional installation channels are not accepted release targets. +Windows, Android/Termux and direct standalone installation are not accepted release targets. +The Homebrew channel below is implemented but remains unavailable until its redistribution and native qualification gates are approved. The hosted runtime image is for the cloud service. ## Install and verify @@ -39,6 +40,28 @@ Changing Node installations can change the npm prefix; check `command -v diffex` Start `diffex` in your project directory, use `/login` to configure your provider, and send a short prompt. See [Quickstart](quickstart.md) and [Providers](providers.md) for authentication and execution. +## Homebrew (pending first qualified release) + +The official tap is not enabled yet. After its first separately authorized release, use the explicit formula name: + +```sh +brew install diffexai/tap/diffex +diffex --version +diffex --help +brew upgrade diffexai/tap/diffex +brew uninstall diffexai/tap/diffex +``` + +Homebrew installs a prebuilt, SHA-256-pinned application archive with private Node/npm; it does not run npm to install Diffex and requires no separately installed Node/npm. Initial targets are macOS 15+ and Ubuntu 24.04-class glibc Linux (glibc 2.39+), arm64/x64. Musl/Alpine and older systems are unsupported. The formula also installs `fd`, Git and ripgrep. + +Homebrew owns upgrades. `diffex update`, `diffex update --self` and `--force` only print `brew upgrade diffexai/tap/diffex`; they do not run Brew/npm or change the application or user data. `diffex update --extensions` still updates extensions; `--all` retains extension updates but only prints the Brew command for Diffex itself. Repair with `brew reinstall diffexai/tap/diffex`. + +Extensions keep their existing user/project storage. Unless you explicitly configure `npmCommand`, extension installation uses the private npm with bundled Node on subprocess PATH. Existing project trust and lifecycle-script behavior is unchanged. Git, shell utilities, OS credential/clipboard services and native extension build tools may still be needed. + +Check `command -v diffex` before migrating from npm or pnpm. Uninstall the old application with its owning package manager, not by deleting a shared prefix; then verify the Brew executable is first on PATH. No installer automatically overwrites or adopts another channel. Homebrew uninstall retains `~/.diffex/agent`, project `.diffex` directories, credentials, sessions and separately configured session directories. Homebrew normally tracks the tap's current version; for an exact reviewed version, use an approved historical formula/tap commit and verify its pinned archive hashes rather than changing its URL alone. + +Homebrew's trust anchor is the reviewed tap/formula and its archive SHA-256. Downloading an adjacent checksum, signature or public key is not independent authentication. Bundled runtime security fixes ship as reviewed Diffex archive/formula updates. There is no supported `curl | sh` installer or standalone self-update in this channel. + ## Install with pnpm pnpm installs the same package from npmjs.org; no separate registry or release is required: diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index a6ca4f851..d6046d481 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -66,6 +66,7 @@ npm:pkg - User installs go under `~/.diffex/agent/npm/`. - Project installs go under `.diffex/npm/`. - Set `npmCommand` in `settings.json` to pin npm package lookup and install operations to a specific wrapper command such as `mise` or `asdf`. +- Homebrew payloads default to private npm invoked with bundled Node; an explicit `npmCommand` takes precedence. Extension subprocesses can locate bundled Node without a system Node/npm installation. Packages still install in the user/project directories above, never the immutable Homebrew application. Git and native build tools may remain external prerequisites. Example: diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 6545fa17f..2bece22d7 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -2,6 +2,7 @@ import { accessSync, constants, existsSync, readFileSync, realpathSync } from "f import { homedir } from "os"; import { basename, dirname, join, resolve, sep, win32 } from "path"; import { fileURLToPath } from "url"; +import { HOMEBREW_UPDATE_COMMAND, isHomebrewInstall } from "./utils/bundled-runtime.ts"; import { spawnProcessSync } from "./utils/child-process.ts"; import { normalizePath } from "./utils/paths.ts"; @@ -26,7 +27,7 @@ export const isBunRuntime = !!process.versions.bun; // Install Method Detection // ============================================================================= -export type InstallMethod = "bun-binary" | "npm" | "pnpm" | "yarn" | "bun" | "unknown"; +export type InstallMethod = "homebrew" | "bun-binary" | "npm" | "pnpm" | "yarn" | "bun" | "unknown"; interface SelfUpdateCommandStep { command: string; @@ -71,6 +72,7 @@ function makeSelfUpdateCommandStep(command: string, args: string[]): SelfUpdateC } export function detectInstallMethod(): InstallMethod { + if (isHomebrewInstall(getPackageDir())) return "homebrew"; if (isBunBinary) { return "bun-binary"; } @@ -120,6 +122,7 @@ function getSelfUpdateCommandForMethod( ): SelfUpdateCommand | undefined { const target = normalizeSelfUpdatePackageTarget(updatePackageTarget); switch (method) { + case "homebrew": case "bun-binary": return undefined; case "pnpm": { @@ -258,6 +261,7 @@ function getGlobalPackageRoots(method: InstallMethod, _packageName: string, npmC } return roots; } + case "homebrew": case "bun-binary": case "unknown": return []; @@ -348,6 +352,7 @@ export function getSelfUpdateUnavailableInstruction( ): string { const method = detectInstallMethod(); const target = normalizeSelfUpdatePackageTarget(updatePackageTarget); + if (method === "homebrew") return `Run: ${HOMEBREW_UPDATE_COMMAND}`; if (method === "bun-binary") { return `Standalone updates are deferred for this release. The supported distribution is the @diffexai/diffex npm package.`; } diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 2b0357eaf..0640a5df0 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -28,7 +28,8 @@ import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; -import { CONFIG_DIR_NAME } from "../config.ts"; +import { CONFIG_DIR_NAME, getPackageDir } from "../config.ts"; +import { getBundledRuntime, withBundledRuntimePath } from "../utils/bundled-runtime.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts"; import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts"; @@ -1720,7 +1721,8 @@ export class DefaultPackageManager implements PackageManager { private getNpmCommand(): { command: string; args: string[] } { const configuredCommand = this.settingsManager.getNpmCommand(); if (!configuredCommand || configuredCommand.length === 0) { - return { command: "npm", args: [] }; + const runtime = getBundledRuntime(getPackageDir()); + return runtime ? { command: runtime.node, args: [runtime.npmCli] } : { command: "npm", args: [] }; } const [command, ...args] = configuredCommand; if (!command) { @@ -1733,8 +1735,11 @@ export class DefaultPackageManager implements PackageManager { const npmCommand = this.getNpmCommand(); const commandParts = [npmCommand.command, ...npmCommand.args]; const separatorIndex = commandParts.lastIndexOf("--"); - const packageManagerCommand = separatorIndex >= 0 ? commandParts[separatorIndex + 1] : npmCommand.command; - return packageManagerCommand ? basename(packageManagerCommand).replace(/\.(cmd|exe)$/i, "") : ""; + const commandIndex = separatorIndex >= 0 ? separatorIndex + 1 : 0; + const packageManagerCommand = commandParts[commandIndex]; + const name = packageManagerCommand ? basename(packageManagerCommand).replace(/\.(cmd|exe)$/i, "") : ""; + if (name === "node" && basename(commandParts[commandIndex + 1] ?? "") === "npm-cli.js") return "npm"; + return name; } private async runNpmCommand(args: string[], options?: { cwd?: string }): Promise { @@ -2580,7 +2585,7 @@ export class DefaultPackageManager implements PackageManager { } private spawnCommand(command: string, args: string[], options?: { cwd?: string }): ChildProcess { - const env = getEnv(); + const env = withBundledRuntimePath(getEnv(), getPackageDir()); return spawnProcess(command, args, { cwd: options?.cwd, stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit", @@ -2593,7 +2598,7 @@ export class DefaultPackageManager implements PackageManager { args: string[], options?: { cwd?: string; env?: Record }, ): ChildProcessByStdio { - const baseEnv = getEnv(); + const baseEnv = withBundledRuntimePath(getEnv(), getPackageDir()); const env = options?.env ? { ...baseEnv, ...options.env } : baseEnv; return spawnProcess(command, args, { cwd: options?.cwd, @@ -2661,7 +2666,7 @@ export class DefaultPackageManager implements PackageManager { } private runCommandSync(command: string, args: string[]): string { - const env = getEnv(); + const env = withBundledRuntimePath(getEnv(), getPackageDir()); const result = spawnProcessSync(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 7914e0b99..527711f29 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -75,7 +75,7 @@ import { builtInExtensions } from "./extensions/index.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; -import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; +import { handleConfigCommand, handleHomebrewSelfUpdate, handlePackageCommand } from "./package-manager-cli.ts"; import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; @@ -374,6 +374,9 @@ export interface MainOptions { } export async function main(args: string[], options?: MainOptions) { + // Homebrew owns self-updates; do not bootstrap settings, HTTP, or resources. + if (handleHomebrewSelfUpdate(args)) return; + resetTimings(); const extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])]; const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.DIFFEX_OFFLINE); diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 704fce024..a00ee7550 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -23,6 +23,7 @@ import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { DefaultResourceLoader } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; +import { HOMEBREW_UPDATE_COMMAND } from "./utils/bundled-runtime.ts"; import { isStableVersion, RELEASE_PACKAGE_NAME } from "./utils/release-contract.ts"; import { getSelfUpdateFailureHints, runSelfUpdate } from "./utils/self-update.ts"; import { @@ -677,6 +678,25 @@ export async function handleConfigCommand( process.exit(0); } +/** Handle only valid, pure Homebrew self-updates before settings or runtime bootstrap. */ +export function handleHomebrewSelfUpdate(args: string[]): boolean { + const options = parsePackageCommand(args); + if ( + options?.command !== "update" || + options.updateTarget?.type !== "self" || + options.help || + options.invalidOption || + options.invalidArgument || + options.missingOptionValue || + options.conflictingOptions || + detectInstallMethod() !== "homebrew" + ) { + return false; + } + console.log(`Run: ${HOMEBREW_UPDATE_COMMAND}`); + return true; +} + export async function handlePackageCommand( args: string[], runtimeOptions: PackageCommandRuntimeOptions = {}, @@ -727,6 +747,8 @@ export async function handlePackageCommand( return true; } + if (handleHomebrewSelfUpdate(args)) return true; + if (options.command === "update" && options.updateTarget?.type === "models") { try { await refreshModelCatalogs(getAgentDir()); @@ -860,6 +882,10 @@ export async function handlePackageCommand( } } if (updateTargetIncludesSelf(target)) { + if (detectInstallMethod() === "homebrew") { + console.log(`Run: ${HOMEBREW_UPDATE_COMMAND}`); + return true; + } const selfUpdatePlan = await getSelfUpdatePlan(options.force); if (!selfUpdatePlan.shouldRun) { return true; diff --git a/packages/coding-agent/src/utils/bundled-runtime.ts b/packages/coding-agent/src/utils/bundled-runtime.ts new file mode 100644 index 000000000..1fc9e68a1 --- /dev/null +++ b/packages/coding-agent/src/utils/bundled-runtime.ts @@ -0,0 +1,95 @@ +import { accessSync, closeSync, constants, fstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, delimiter, dirname, join, resolve, sep } from "node:path"; + +export const HOMEBREW_UPDATE_COMMAND = "brew upgrade diffexai/tap/diffex"; + +export interface BundledRuntime { + root: string; + binDir: string; + node: string; + npmCli: string; +} + +/** Canonical descendants only; the installation root itself remains caller-selected. */ +function bundledPath(root: string, ...components: string[]): string { + const path = resolve(root, ...components); + if (!path.startsWith(root + sep) || realpathSync(path) !== path) throw new Error("Invalid bundled path"); + return path; +} + +/** Inspect and read the same regular file, without following a replaced final symlink. */ +function readBundledJson(root: string, ...components: string[]): unknown { + const fd = openSync( + bundledPath(root, ...components), + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const info = fstatSync(fd); + if (!info.isFile() || info.size > 64 * 1024) throw new Error("Invalid bundled JSON file"); + return JSON.parse(readFileSync(fd, "utf-8")); + } finally { + closeSync(fd); + } +} + +/** + * Distribution contract: /{bin/diffex,runtime/bin/node, + * runtime/lib/node_modules/npm/bin/npm-cli.js,app/node_modules/@diffexai/diffex}. + * Resolve the package physically first, so aliases (including Homebrew opt links) + * work, but individual payload components cannot escape through symlinks. + */ +export function getBundledRuntime(packageDir: string): BundledRuntime | undefined { + try { + const physicalPackageDir = realpathSync(packageDir); + const root = resolve(physicalPackageDir, "../../../.."); + if (physicalPackageDir !== join(root, "app", "node_modules", "@diffexai", "diffex")) return undefined; + const binDir = bundledPath(root, "runtime", "bin"); + const node = bundledPath(root, "runtime", "bin", "node"); + const npmCli = bundledPath(root, "runtime", "lib", "node_modules", "npm", "bin", "npm-cli.js"); + for (const dir of [bundledPath(root, "bin"), bundledPath(root, "runtime"), bundledPath(root, "app"), binDir]) { + if (!statSync(dir).isDirectory()) return undefined; + } + for (const file of [ + node, + npmCli, + bundledPath(root, "bin", "diffex"), + bundledPath(root, "app", "node_modules", "@diffexai", "diffex", "package.json"), + bundledPath(root, "app", "node_modules", "@diffexai", "diffex", "dist", "cli.js"), + ]) { + if (!statSync(file).isFile()) return undefined; + } + accessSync(node, constants.X_OK); + accessSync(bundledPath(root, "bin", "diffex"), constants.X_OK); + const manifest = readBundledJson(root, "app", "node_modules", "@diffexai", "diffex", "package.json"); + if (!manifest || typeof manifest !== "object" || !("name" in manifest) || manifest.name !== "@diffexai/diffex") + return undefined; + return { root, binDir, node, npmCli }; + } catch { + return undefined; + } +} + +/** Homebrew owns only an external marker; it never modifies the signed payload. */ +export function isHomebrewInstall(packageDir: string): boolean { + const runtime = getBundledRuntime(packageDir); + if (!runtime) return false; + try { + if (basename(dirname(runtime.root)) !== "libexec" || basename(runtime.root) !== "diffex") return false; + const marker = readBundledJson(dirname(runtime.root), ".diffex-homebrew.json"); + if (!marker || typeof marker !== "object" || Array.isArray(marker)) return false; + const fields = marker as Record; + return ( + fields.schemaVersion === 1 && fields.package === "@diffexai/diffex" && fields.formula === "diffexai/tap/diffex" + ); + } catch { + return false; + } +} + +/** Preserve the caller environment without modifying process.env. */ +export function withBundledRuntimePath(env: NodeJS.ProcessEnv, packageDir: string): NodeJS.ProcessEnv { + const runtime = getBundledRuntime(packageDir); + if (!runtime) return env; + const pathKey = Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "PATH"; + return { ...env, [pathKey]: [runtime.binDir, env[pathKey]].filter(Boolean).join(delimiter) }; +} diff --git a/packages/coding-agent/test/bundled-runtime.test.ts b/packages/coding-agent/test/bundled-runtime.test.ts new file mode 100644 index 000000000..a7e282b45 --- /dev/null +++ b/packages/coding-agent/test/bundled-runtime.test.ts @@ -0,0 +1,517 @@ +import { type ChildProcess, spawnSync } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { + chmodSync, + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join, relative, resolve } from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + detectInstallMethod, + getSelfUpdateCommand, + getSelfUpdateUnavailableInstruction, + getUpdateInstruction, +} from "../src/config.ts"; +import * as httpDispatcher from "../src/core/http-dispatcher.ts"; +import { DefaultPackageManager } from "../src/core/package-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import { main } from "../src/main.ts"; +import { handleHomebrewSelfUpdate, handlePackageCommand } from "../src/package-manager-cli.ts"; +import { getBundledRuntime, isHomebrewInstall, withBundledRuntimePath } from "../src/utils/bundled-runtime.ts"; +import * as childProcesses from "../src/utils/child-process.ts"; +import * as versionCheck from "../src/utils/version-check.ts"; + +const marker = { schemaVersion: 1, package: "@diffexai/diffex", formula: "diffexai/tap/diffex" }; +const brewInstruction = "Run: brew upgrade diffexai/tap/diffex"; +let tempDir: string; +let root: string; +let packageDir: string; +let node: string; +let npmCli: string; +let markerPath: string; +const originalExecPath = process.execPath; +const execPathDescriptor = Object.getOwnPropertyDescriptor(process, "execPath"); +const originalExitCode = process.exitCode; + +function writeFixture(path: string, content = ""): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +beforeEach(() => { + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "diffex-runtime-"))); + root = join(tempDir, "libexec", "diffex"); + packageDir = join(root, "app", "node_modules", "@diffexai", "diffex"); + node = join(root, "runtime", "bin", "node"); + npmCli = join(root, "runtime", "lib", "node_modules", "npm", "bin", "npm-cli.js"); + markerPath = join(tempDir, "libexec", ".diffex-homebrew.json"); + writeFixture(node); + writeFixture(npmCli); + writeFixture(join(root, "bin", "diffex")); + writeFixture(join(packageDir, "dist", "cli.js")); + writeFixture(join(packageDir, "package.json"), JSON.stringify({ name: "@diffexai/diffex" })); + chmodSync(node, 0o755); + chmodSync(join(root, "bin", "diffex"), 0o755); + vi.stubEnv("DIFFEX_PACKAGE_DIR", packageDir); + vi.stubEnv("DIFFEX_CODING_AGENT_DIR", join(tempDir, "agent")); + vi.stubEnv("DIFFEX_OFFLINE", ""); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + if (execPathDescriptor) Object.defineProperty(process, "execPath", execPathDescriptor); + process.exitCode = originalExitCode; + rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("bundled runtime layout", () => { + it("discovers the private runtime without requiring Homebrew ownership", () => { + expect(getBundledRuntime(packageDir)).toEqual({ root, binDir: dirname(node), node, npmCli }); + expect(isHomebrewInstall(packageDir)).toBe(false); + }); + + it("resolves installation aliases physically", () => { + const alias = join(tempDir, "opt"); + symlinkSync(root, alias, "junction"); + writeFixture(markerPath, JSON.stringify(marker)); + expect(isHomebrewInstall(join(alias, "app", "node_modules", "@diffexai", "diffex"))).toBe(true); + }); + + it.each([ + "bin/diffex", + "runtime/bin/node", + "runtime/lib/node_modules/npm/bin/npm-cli.js", + "app/node_modules/@diffexai/diffex/dist/cli.js", + ])("rejects an incomplete payload missing %s", (path) => { + writeFixture(markerPath, JSON.stringify(marker)); + rmSync(join(root, path)); + expect(getBundledRuntime(packageDir)).toBeUndefined(); + expect(isHomebrewInstall(packageDir)).toBe(false); + }); + + it("rejects payload components redirected outside the payload", () => { + writeFixture(markerPath, JSON.stringify(marker)); + const external = join(tempDir, "external-npm.js"); + writeFixture(external); + rmSync(npmCli); + symlinkSync(external, npmCli); + expect(getBundledRuntime(packageDir)).toBeUndefined(); + expect(isHomebrewInstall(packageDir)).toBe(false); + }); + + it.each([ + "{", + "null", + "[]", + JSON.stringify({ ...marker, schemaVersion: 2 }), + JSON.stringify({ ...marker, package: "other" }), + JSON.stringify({ ...marker, formula: "diffex" }), + ])("rejects invalid ownership marker %s", (content) => { + writeFixture(markerPath, content); + expect(isHomebrewInstall(packageDir)).toBe(false); + }); + + it("does not accept a marker inside the signed payload or a symlinked marker", () => { + const internalMarker = join(root, ".diffex-homebrew.json"); + writeFixture(internalMarker, JSON.stringify(marker)); + expect(isHomebrewInstall(packageDir)).toBe(false); + symlinkSync(internalMarker, markerPath); + expect(isHomebrewInstall(packageDir)).toBe(false); + }); + + it("supports relocated package overrides outside a Homebrew layout", () => { + const relocated = join(tempDir, "custom installation"); + renameSync(root, relocated); + expect(getBundledRuntime(join(relocated, "app", "node_modules", "@diffexai", "diffex"))?.root).toBe(relocated); + expect(isHomebrewInstall(join(relocated, "app", "node_modules", "@diffexai", "diffex"))).toBe(false); + }); + + it("rejects a symlinked package manifest even when it has the expected identity", () => { + const manifest = join(packageDir, "package.json"); + const external = join(tempDir, "manifest.json"); + writeFixture(external, JSON.stringify({ name: "@diffexai/diffex" })); + rmSync(manifest); + symlinkSync(external, manifest); + expect(getBundledRuntime(packageDir)).toBeUndefined(); + }); + + it("rejects a payload runtime directory replaced by a sibling-prefix alias", () => { + const sibling = join(tempDir, "libexec", "diffex-other"); + writeFixture(join(sibling, "bin", "node")); + rmSync(join(root, "runtime"), { recursive: true }); + symlinkSync(sibling, join(root, "runtime"), "junction"); + expect(getBundledRuntime(packageDir)).toBeUndefined(); + }); + + it("requires the expected package identity", () => { + writeFixture(join(packageDir, "package.json"), JSON.stringify({ name: "other" })); + expect(getBundledRuntime(packageDir)).toBeUndefined(); + }); + + it("prepends bundled runtime/bin without mutating the parent environment", () => { + const env = { PATH: "/user/bin", CUSTOM: "value" }; + expect(withBundledRuntimePath(env, packageDir)).toEqual({ + ...env, + PATH: `${dirname(node)}${delimiter}/user/bin`, + }); + expect(env.PATH).toBe("/user/bin"); + expect(withBundledRuntimePath({ Path: "/user/bin" }, packageDir)).toEqual({ + Path: `${dirname(node)}${delimiter}/user/bin`, + }); + }); +}); + +describe("Homebrew ownership", () => { + beforeEach(() => writeFixture(markerPath, JSON.stringify(marker))); + + it("takes priority over npm/pnpm heuristics and configured npm wrappers without spawning", () => { + Object.defineProperty(process, "execPath", { value: "/pnpm/node_modules/node", configurable: true }); + const sync = vi.spyOn(childProcesses, "spawnProcessSync").mockImplementation(() => { + throw new Error("must not discover global npm"); + }); + expect(detectInstallMethod()).toBe("homebrew"); + expect(getSelfUpdateCommand("@diffexai/diffex", ["wrapper"])).toBeUndefined(); + expect(getSelfUpdateUnavailableInstruction("@diffexai/diffex", ["wrapper"])).toBe(brewInstruction); + expect(getUpdateInstruction("@diffexai/diffex")).toBe(brewInstruction); + expect(sync).not.toHaveBeenCalled(); + }); + + it.each([ + ["update"], + ["update", "--self"], + ["update", "self"], + ["update", "diffex"], + ["update", "--force"], + ["update", "--self", "--force"], + ])("prints the offline instruction before discovery for %j", async (...args) => { + vi.stubEnv("DIFFEX_OFFLINE", "1"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const settings = vi.spyOn(SettingsManager, "create").mockImplementation(() => { + throw new Error("must not read settings"); + }); + const latest = vi.spyOn(versionCheck, "getLatestDiffexRelease").mockRejectedValue(new Error("offline")); + const sync = vi.spyOn(childProcesses, "spawnProcessSync"); + const asyncSpawn = vi.spyOn(childProcesses, "spawnProcess"); + expect(await handlePackageCommand(args)).toBe(true); + expect(log).toHaveBeenCalledExactlyOnceWith(brewInstruction); + expect(settings).not.toHaveBeenCalled(); + expect(latest).not.toHaveBeenCalled(); + expect(sync).not.toHaveBeenCalled(); + expect(asyncSpawn).not.toHaveBeenCalled(); + expect(existsSync(join(tempDir, "agent"))).toBe(false); + }); + + it("handles pure self-update before main settings and HTTP bootstrap", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const settings = vi.spyOn(SettingsManager, "create").mockImplementation(() => { + throw new Error("must not bootstrap settings"); + }); + const http = vi.spyOn(httpDispatcher, "configureHttpDispatcher"); + const proxy = vi.spyOn(httpDispatcher, "applyHttpProxySettings"); + const latest = vi.spyOn(versionCheck, "getLatestDiffexRelease"); + const sync = vi.spyOn(childProcesses, "spawnProcessSync"); + const asyncSpawn = vi.spyOn(childProcesses, "spawnProcess"); + await main(["update", "--self", "--force"]); + expect(log).toHaveBeenCalledExactlyOnceWith(brewInstruction); + expect(settings).not.toHaveBeenCalled(); + expect(http).not.toHaveBeenCalled(); + expect(proxy).not.toHaveBeenCalled(); + expect(latest).not.toHaveBeenCalled(); + expect(sync).not.toHaveBeenCalled(); + expect(asyncSpawn).not.toHaveBeenCalled(); + expect(existsSync(join(tempDir, "agent"))).toBe(false); + }); + + it.each([ + ["update", "--all"], + ["update", "--extensions"], + ["update", "--models"], + ["update", "--help"], + ["update", "--unknown"], + ["update", "--self", "extra"], + ["update", "--self", "--models"], + ["update", "--extension"], + ["--help"], + ["--list-models"], + ])("does not intercept non-self/help/invalid invocation %j", (...args) => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect(handleHomebrewSelfUpdate(args)).toBe(false); + expect(log).not.toHaveBeenCalled(); + }); + + it + .skipIf(process.platform === "win32") + .each([ + ["update"], + ["update", "--self"], + ["update", "self"], + ["update", "diffex"], + ["update", "--force"], + ["update", "--self", "--force"], + ])("runs the actual offline CLI without writing user/workspace/payload state for %j", (...args) => { + const home = join(tempDir, "home"); + const workspace = join(tempDir, "workspace"); + mkdirSync(home); + mkdirSync(workspace); + const snapshot = () => + readdirSync(tempDir, { recursive: true, withFileTypes: true }) + .map((entry) => { + const fullPath = join(entry.parentPath, entry.name); + const path = relative(tempDir, fullPath); + if (!entry.isFile()) { + const stats = lstatSync(fullPath); + return { path, mode: stats.mode, mtimeMs: stats.mtimeMs, content: undefined }; + } + const fd = openSync(fullPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600); + try { + const stats = fstatSync(fd); + if (!stats.isFile()) throw new Error("Fixture snapshot requires a regular file"); + return { path, mode: stats.mode, mtimeMs: stats.mtimeMs, content: readFileSync(fd, "base64") }; + } finally { + closeSync(fd); + } + }) + .sort((a, b) => a.path.localeCompare(b.path)); + const before = snapshot(); + const result = spawnSync( + originalExecPath, + [`--import=${import.meta.resolve("tsx")}`, resolve(import.meta.dirname, "../src/cli.ts"), ...args], + { + cwd: workspace, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, "config"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_DATA_HOME: join(home, "data"), + PATH: "/nonexistent-system-bin", + DIFFEX_OFFLINE: "1", + TSX_DISABLE_CACHE: "1", + TSX_TSCONFIG_PATH: resolve(import.meta.dirname, "../../../tsconfig.json"), + }, + encoding: "utf-8", + timeout: 15_000, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(brewInstruction); + expect(result.stderr).toBe(""); + expect(snapshot()).toEqual(before); + expect(existsSync(join(tempDir, "agent"))).toBe(false); + }); + + it("preserves --all extension updates and skips self-update release/npm discovery", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const settings = SettingsManager.inMemory({}, { projectTrusted: false }); + vi.spyOn(SettingsManager, "create").mockReturnValue(settings); + const update = vi.spyOn(DefaultPackageManager.prototype, "update").mockResolvedValue(); + const latest = vi.spyOn(versionCheck, "getLatestDiffexRelease").mockRejectedValue(new Error("offline")); + const sync = vi.spyOn(childProcesses, "spawnProcessSync"); + expect(await handlePackageCommand(["update", "--all"])).toBe(true); + expect(update).toHaveBeenCalledExactlyOnceWith(undefined); + expect(settings.isProjectTrusted()).toBe(false); + expect(log).toHaveBeenCalledWith(brewInstruction); + expect(latest).not.toHaveBeenCalled(); + expect(sync).not.toHaveBeenCalled(); + }); + + it("leaves extension-only updates available without a brew instruction", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(SettingsManager, "create").mockReturnValue(SettingsManager.inMemory()); + const update = vi.spyOn(DefaultPackageManager.prototype, "update").mockResolvedValue(); + expect(await handlePackageCommand(["update", "--extensions"])).toBe(true); + expect(update).toHaveBeenCalled(); + expect(log).not.toHaveBeenCalledWith(brewInstruction); + }); +}); + +describe("bundled private npm extension commands", () => { + function mockSpawn() { + return vi.spyOn(childProcesses, "spawnProcess").mockImplementation(() => { + const child = new EventEmitter(); + Object.assign(child, { stdout: new PassThrough(), stderr: new PassThrough() }); + queueMicrotask(() => child.emit("exit", 0)); + return child as ChildProcess; + }); + } + + it("uses private npm for installs/removals, with runtime PATH and existing lifecycle flags", async () => { + const spawn = mockSpawn(); + const manager = new DefaultPackageManager({ + cwd: tempDir, + agentDir: join(tempDir, "agent"), + settingsManager: SettingsManager.inMemory(), + }); + await manager.install("npm:example-extension@1.0.0"); + await manager.remove("npm:example-extension"); + expect(spawn).toHaveBeenNthCalledWith( + 1, + node, + [ + npmCli, + "install", + "example-extension@1.0.0", + "--prefix", + join(tempDir, "agent", "npm"), + "--legacy-peer-deps", + ], + expect.objectContaining({ + env: expect.objectContaining({ + PATH: expect.stringMatching(new RegExp(`^${dirname(node).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)), + }), + }), + ); + expect(spawn.mock.calls[0][1]).not.toContain("--ignore-scripts"); + expect(spawn).toHaveBeenNthCalledWith( + 2, + node, + [npmCli, "uninstall", "example-extension", "--prefix", join(tempDir, "agent", "npm"), "--legacy-peer-deps"], + expect.anything(), + ); + }); + + it("uses private npm and runtime PATH for git dependency installs", async () => { + const spawn = mockSpawn(); + const target = join(tempDir, "agent", "git", "github.com", "example", "extension"); + spawn.mockImplementation((_command, args) => { + if (args[0] === "clone") writeFixture(join(target, "package.json"), "{}"); + const child = new EventEmitter(); + queueMicrotask(() => child.emit("exit", 0)); + return child as ChildProcess; + }); + const manager = new DefaultPackageManager({ + cwd: tempDir, + agentDir: join(tempDir, "agent"), + settingsManager: SettingsManager.inMemory(), + }); + await manager.install("git:github.com/example/extension"); + expect(spawn.mock.calls[0][0]).toBe("git"); + expect(spawn.mock.calls[0][2].env?.PATH?.split(delimiter)[0]).toBe(dirname(node)); + expect(spawn).toHaveBeenLastCalledWith( + node, + [npmCli, "install", "--omit=dev"], + expect.objectContaining({ cwd: target }), + ); + }); + + it("honors explicit npmCommand instead of the private default and retains bundled PATH", async () => { + const spawn = mockSpawn(); + const settings = SettingsManager.inMemory({ npmCommand: ["wrapper", "--", "pnpm"] }); + const manager = new DefaultPackageManager({ + cwd: tempDir, + agentDir: join(tempDir, "agent"), + settingsManager: settings, + }); + await manager.install("npm:example-extension"); + expect(spawn.mock.calls[0][0]).toBe("wrapper"); + expect(spawn.mock.calls[0][1]).toEqual([ + "--", + "pnpm", + "install", + "example-extension", + "--prefix", + join(tempDir, "agent", "npm"), + "--config.auto-install-peers=false", + "--config.strict-peer-dependencies=false", + "--config.strict-dep-builds=false", + ]); + expect(spawn.mock.calls[0][2].env?.PATH?.split(delimiter)[0]).toBe(dirname(node)); + }); + + it("identifies explicitly Node-invoked npm as npm for remove flags", async () => { + const spawn = mockSpawn(); + const settings = SettingsManager.inMemory({ npmCommand: ["node", npmCli] }); + const manager = new DefaultPackageManager({ + cwd: tempDir, + agentDir: join(tempDir, "agent"), + settingsManager: settings, + }); + mkdirSync(join(tempDir, "agent", "npm"), { recursive: true }); + await manager.remove("npm:example-extension"); + expect(spawn.mock.calls[0][0]).toBe("node"); + expect((manager as unknown as { getPackageManagerName(): string }).getPackageManagerName()).toBe("npm"); + expect(spawn.mock.calls[0][1]).toContain("--legacy-peer-deps"); + }); + + it("uses system npm for ordinary installs without a bundled payload", async () => { + const spawn = mockSpawn(); + vi.stubEnv("DIFFEX_PACKAGE_DIR", tempDir); + const manager = new DefaultPackageManager({ + cwd: tempDir, + agentDir: join(tempDir, "agent"), + settingsManager: SettingsManager.inMemory(), + }); + await manager.install("npm:example-extension"); + expect(spawn.mock.calls[0][0]).toBe("npm"); + expect(spawn.mock.calls[0][1][0]).toBe("install"); + }); + + it("refuses project npm installation before spawning in an untrusted workspace", async () => { + const spawn = mockSpawn(); + const manager = new DefaultPackageManager({ + cwd: tempDir, + agentDir: join(tempDir, "agent"), + settingsManager: SettingsManager.inMemory({}, { projectTrusted: false }), + }); + await expect(manager.install("npm:example-extension", { local: true })).rejects.toThrow("Project is not trusted"); + expect(spawn).not.toHaveBeenCalled(); + expect(existsSync(join(tempDir, ".diffex", "npm"))).toBe(false); + }); + + it.skipIf(process.platform === "win32")( + "executes private npm with no system npm and exposes node to lifecycle subprocesses", + async () => { + // A local fake npm CLI exercises actual process execution without registry access. + writeFixture(node, `#!/bin/sh\nexec ${JSON.stringify(originalExecPath)} "$@"\n`); + chmodSync(node, 0o755); + const recordPath = join(tempDir, "npm-execution.json"); + writeFixture( + npmCli, + `const fs = require("node:fs"); const cp = require("node:child_process"); fs.appendFileSync(${JSON.stringify(recordPath)}, JSON.stringify({args: process.argv.slice(2), path: process.env.PATH, child: cp.spawnSync("node", ["-p", "'lifecycle-ok'"], {encoding:"utf8"}).stdout.trim()}) + "\\n"); if (process.argv[2] === "root") console.log(${JSON.stringify(join(tempDir, "missing-global"))}); if (process.argv[2] === "view") console.log('"1.1.0"');`, + ); + vi.stubEnv("PATH", "/nonexistent-system-bin"); + const manager = new DefaultPackageManager({ + cwd: tempDir, + agentDir: join(tempDir, "agent"), + settingsManager: SettingsManager.inMemory({ packages: ["npm:local-fixture"] }), + }); + expect(manager.getInstalledPath("npm:missing", "user")).toBeUndefined(); + await manager.install("npm:local-fixture"); + writeFixture( + join(tempDir, "agent", "npm", "node_modules", "local-fixture", "package.json"), + JSON.stringify({ version: "1.0.0" }), + ); + await manager.update(); + const records = readFileSync(recordPath, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { args: string[]; path: string; child: string }); + expect(records.map((record) => record.args[0])).toEqual(["root", "install", "view", "install"]); + for (const record of records) { + expect(record.args).not.toContain("--ignore-scripts"); + expect(record.path.split(delimiter)[0]).toBe(dirname(node)); + expect(record.child).toBe("lifecycle-ok"); + } + expect((spawnSync("npm", ["--version"]).error as NodeJS.ErrnoException | undefined)?.code).toBe("ENOENT"); + }, + ); +}); diff --git a/packages/coding-agent/test/module-loading.md b/packages/coding-agent/test/module-loading.md index ef5adedd4..4fb46a227 100644 --- a/packages/coding-agent/test/module-loading.md +++ b/packages/coding-agent/test/module-loading.md @@ -54,7 +54,7 @@ Restoring the source passed all 36 tests again. `DEBUG=vitest:cache:fs` exposes cache reads and writes for this check. The affected test files and shared harness consumers passed 212 tests, with 26 credential-dependent tests skipped. -The full offline suite passed through `./test.sh`, including 2,862 coding-agent tests with 49 expected skips. +The full offline suite passed through `./scripts/test.sh`, including 2,862 coding-agent tests with 49 expected skips. The suite required execution outside the filesystem/network sandbox for localhost test servers. `npm run check` and static workflow validation passed. @@ -85,9 +85,9 @@ The existing `build-check-test` required check succeeds only when every group su Cache keys include the shard number and count to avoid reusing a partial cache from a different shard. ```bash -./test.sh coding-agent --shard=1/4 -./test.sh other -./test.sh other --shard=1/2 +./scripts/test.sh coding-agent --shard=1/4 +./scripts/test.sh other +./scripts/test.sh other --shard=1/2 ``` Run `CI benchmark` manually, or add the `ci-benchmark` label to a pull request containing that workflow. diff --git a/packages/coding-agent/test/production-context-files.test.ts b/packages/coding-agent/test/production-context-files.test.ts index 7f6a976f5..483934892 100644 --- a/packages/coding-agent/test/production-context-files.test.ts +++ b/packages/coding-agent/test/production-context-files.test.ts @@ -21,7 +21,7 @@ describe("production default context files", () => { let agentDir: string; let cwd: string; let defaultPath: string; - const instructions = readFileSync(new URL("../../../AGENTS_release.md", import.meta.url), "utf8"); + const instructions = readFileSync(new URL("../../../scripts/assets/default-agent.md", import.meta.url), "utf8"); beforeEach(() => { directory = mkdtempSync(join(tmpdir(), "diffex-production-context-")); @@ -46,7 +46,7 @@ describe("production default context files", () => { expect(loader.getAgentsFiles().agentsFiles[0]).toEqual({ path: defaultPath, content: instructions }); const prompt = buildSystemPrompt({ cwd, contextFiles: loader.getAgentsFiles().agentsFiles }); expect(prompt).toContain(`\n${instructions}`); - expect(prompt).not.toContain("AGENTS_release.md"); + expect(prompt).not.toContain("scripts/assets/default-agent.md"); const conversationalStyle = [ "When the user asks a question, answer it first before making edits or running implementation commands.", "Keep responses concise and direct, adapting detail and terminology to the user.", diff --git a/Dockerfile.runtime b/scripts/Dockerfile.runtime similarity index 93% rename from Dockerfile.runtime rename to scripts/Dockerfile.runtime index c6f1639a8..65a02d38d 100644 --- a/Dockerfile.runtime +++ b/scripts/Dockerfile.runtime @@ -7,8 +7,6 @@ ENV DIFFEX_SOURCE_COMMIT=$SOURCE_COMMIT COPY package.json package-lock.json tsconfig.base.json ./ COPY LICENSE ./ -COPY legal ./legal -COPY docs/release/distribution-policy.json ./docs/release/distribution-policy.json COPY packages ./packages COPY scripts ./scripts diff --git a/scripts/artifact-installation.mjs b/scripts/artifact-installation.mjs index ff8519981..2c07f5502 100644 --- a/scripts/artifact-installation.mjs +++ b/scripts/artifact-installation.mjs @@ -13,8 +13,9 @@ const readJson = (file) => JSON.parse(readFileSync(file, "utf8")); export function resolveArtifactDirectory(value) { assert(typeof value === "string" && value.length > 0 && !value.includes("\0"), "Invalid artifact directory"); const directory = resolve(value); - for (const base of [process.cwd(), tmpdir(), realpathSync(tmpdir()), homedir(), "/tmp"]) { - const root = resolve(base); + const roots = [process.cwd(), tmpdir(), homedir(), "/tmp"].flatMap((base) => + existsSync(base) ? [resolve(base), realpathSync(base)] : [resolve(base)]); + for (const root of new Set(roots)) { if (!directory.startsWith(root + sep)) continue; let current = root; for (const component of relative(root, directory).split(sep)) { diff --git a/scripts/artifact-installation.test.mjs b/scripts/artifact-installation.test.mjs index ed16ade01..7fc2cc3fa 100644 --- a/scripts/artifact-installation.test.mjs +++ b/scripts/artifact-installation.test.mjs @@ -31,6 +31,20 @@ test("artifact directories stay within trusted roots and reject symlink componen } }); +test("canonical OS temporary-root aliases stay trusted without allowing arbitrary private paths", () => { + if (!existsSync("/tmp")) return; + const root = mkdtempSync(join("/tmp", "diffex-artifact-canonical-")); + try { + const canonical = realpathSync(root); + assert.equal(resolveArtifactDirectory(join(canonical, "new-consumer")), join(canonical, "new-consumer")); + const target = join(canonical, "target"); + mkdirSync(target); + symlinkSync(target, join(canonical, "linked")); + assert.throws(() => resolveArtifactDirectory(join(canonical, "linked", "consumer")), /symlinks/); + assert.throws(() => resolveArtifactDirectory("/private/unapproved-diffex-artifacts"), /workspace, home, or temporary/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test("isolated installation environment copies literal non-secret allowlisted fields only", () => { const root = mkdtempSync(join(tmpdir(), "diffex-artifact-env-")); const original = { ...process.env }; diff --git a/AGENTS_release.md b/scripts/assets/default-agent.md similarity index 100% rename from AGENTS_release.md rename to scripts/assets/default-agent.md diff --git a/legal/upstream/highlight-11.9.0.LICENSE b/scripts/assets/licenses/highlight-11.9.0.LICENSE similarity index 100% rename from legal/upstream/highlight-11.9.0.LICENSE rename to scripts/assets/licenses/highlight-11.9.0.LICENSE diff --git a/legal/upstream/marked-18.0.5.LICENSE b/scripts/assets/licenses/marked-18.0.5.LICENSE similarity index 100% rename from legal/upstream/marked-18.0.5.LICENSE rename to scripts/assets/licenses/marked-18.0.5.LICENSE diff --git a/scripts/assets/licenses/sources.json b/scripts/assets/licenses/sources.json new file mode 100644 index 000000000..413dd45aa --- /dev/null +++ b/scripts/assets/licenses/sources.json @@ -0,0 +1,12 @@ +{ + "marked-18.0.5.LICENSE": [ + "markedjs/marked", + "4063c638cb621c09091d41b26f323ff074416bb9", + "LICENSE" + ], + "highlight-11.9.0.LICENSE": [ + "highlightjs/highlight.js", + "f47103d4f1ac1592c56904574d1fbf5bf2475605", + "LICENSE" + ] +} diff --git a/scripts/bundle-cli-providers.test.mjs b/scripts/bundle-cli-providers.test.mjs index 08e3c522c..0e43a4cf3 100644 --- a/scripts/bundle-cli-providers.test.mjs +++ b/scripts/bundle-cli-providers.test.mjs @@ -21,7 +21,7 @@ function fixture() { mkdirSync(dirname(join(root, file)), { recursive: true }); cpSync(file, join(root, file), { recursive: true }); }; - for (const file of ["package-lock.json", "docs/release/distribution-policy.json", "packages/coding-agent/package.json", "packages/evolution/package.json", "packages/harness-trace/package.json", ...cliDistribution().bundled.map((pkg) => `${pkg.directory}/package.json`)]) copy(file); + for (const file of ["package-lock.json", "scripts/distribution-policy.json", "packages/coding-agent/package.json", "packages/evolution/package.json", "packages/harness-trace/package.json", ...cliDistribution().bundled.map((pkg) => `${pkg.directory}/package.json`)]) copy(file); copy("packages/ai/dist"); // Mirror preparePackageDistribution's map stripping in the temporary copy only. // Calling it directly would archive private debug maps under the real repo root. diff --git a/scripts/catalog-local-e2e.mjs b/scripts/catalog-local-e2e.mjs index 92dd36b70..7ed9fae97 100644 --- a/scripts/catalog-local-e2e.mjs +++ b/scripts/catalog-local-e2e.mjs @@ -66,7 +66,7 @@ const routed = (input, init) => { Object.defineProperty(globalThis, "fetch", { configurable: true, get: () => routed, set: (value) => { original = value; } }); `); const cliEnv = { DIFFEX_CODING_AGENT_DIR: agentDir, NODE_OPTIONS: `--import=${preload}` }; - const cli = join(repo, "diffex-test.sh"); + const cli = join(repo, "scripts/diffex-test.sh"); console.log(await run(cli, ["--no-env", "update", "--models"], cliEnv)); const cache = JSON.parse(readFileSync(join(agentDir, "models-store.json"), "utf8")); assert.equal(cache.openai.models.length, 170); diff --git a/scripts/catalog-staging-rehearsal.mjs b/scripts/catalog-staging-rehearsal.mjs index bc52f1693..4bb994084 100644 --- a/scripts/catalog-staging-rehearsal.mjs +++ b/scripts/catalog-staging-rehearsal.mjs @@ -72,13 +72,13 @@ Object.defineProperty(globalThis, "fetch", { configurable: true, get: () => rout `); const cliEnv = { ...process.env, DIFFEX_CODING_AGENT_DIR: agentDir, NODE_OPTIONS: `--import=${preload}` }; for (let i = 0; i < 2; i++) { - const output = execFileSync("./diffex-test.sh", ["--no-env", "update", "--models"], { env: cliEnv, encoding: "utf8", timeout: 30_000 }); + const output = execFileSync("./scripts/diffex-test.sh", ["--no-env", "update", "--models"], { env: cliEnv, encoding: "utf8", timeout: 30_000 }); assert.match(output, /Model catalogs refreshed/); const cache = JSON.parse(readFileSync(join(agentDir, "models-store.json"), "utf8")); assert.equal(cache.openai.models.length, 170); assert.equal(cache.openai.etag.replace(/^W\//, ""), `"${fixtureRevision}:openai"`); } - const offline = execFileSync("./diffex-test.sh", ["--no-env", "--list-models", "model-0"], { env: { ...cliEnv, DIFFEX_OFFLINE: "1" }, encoding: "utf8", timeout: 30_000 }); + const offline = execFileSync("./scripts/diffex-test.sh", ["--no-env", "--list-models", "model-0"], { env: { ...cliEnv, DIFFEX_OFFLINE: "1" }, encoding: "utf8", timeout: 30_000 }); assert.match(offline, /model-0/); console.log(JSON.stringify({ staging: true, revision: fixtureRevision, interruptedUploadPreservedIndex: true, conditionalConflictsRejected: true, identicalRetryPreservedManifest: true, clientRefreshAndOfflineRecovery: true, statuses: [first.status, revalidated.status, head.status] })); } finally { diff --git a/scripts/check-distribution-payload.py b/scripts/check-distribution-payload.py index c0f38de17..1b4822157 100644 --- a/scripts/check-distribution-payload.py +++ b/scripts/check-distribution-payload.py @@ -81,8 +81,8 @@ def scan_bytes(data, path, budget, depth=0, default_agents=False): if budget['bytes'] > MAX_BYTES or budget['files'] > MAX_FILES: fail(path, 'inspection resource limit exceeded') declaration = check_path(path, default_agents=default_agents) - if default_agents and data != Path(__file__).resolve().parent.parent.joinpath('AGENTS_release.md').read_bytes(): - fail(path, 'prohibited repository instructions: default AGENTS.md differs from AGENTS_release.md') + if default_agents and data != Path(__file__).resolve().parent.parent.joinpath('scripts/assets/default-agent.md').read_bytes(): + fail(path, 'prohibited repository instructions: default AGENTS.md differs from scripts/assets/default-agent.md') if MAP_DIRECTIVE.search(data) or MAP_CONTENT.search(data): fail(path, 'embedded source map') if PurePosixPath(path).suffix in {'.js', '.mjs', '.cjs', '.ts', '.node'} and REMOVED_PROVIDER_RUNTIME.search(data): diff --git a/scripts/check-homebrew-payload.py b/scripts/check-homebrew-payload.py new file mode 100644 index 000000000..931ae7095 --- /dev/null +++ b/scripts/check-homebrew-payload.py @@ -0,0 +1,480 @@ +"""Bounded, extraction-free Homebrew inspection and deterministic archive creation. + +Diffex-owned bytes retain the existing strict disclosure gate. Third-party Node, +npm and installed production packages may retain their upstream source/notices; +that exception never permits credentials, repository state or escaping links. +""" +import argparse +import gzip +import hashlib +import io +import json +import os +import pwd +import re +import stat +import tarfile +import tempfile +from pathlib import Path, PurePosixPath + +import importlib.util +import sys + +sys.dont_write_bytecode = True +spec = importlib.util.spec_from_file_location('distribution_payload', Path(__file__).with_name('check-distribution-payload.py')) +strict = importlib.util.module_from_spec(spec) +spec.loader.exec_module(strict) +MAX_BYTES = 512 * 1024 * 1024 +MAX_FILES = 100000 +CLI = 'app/node_modules/@diffexai/diffex/' +PRIVATE = {'.git', '.hg', '.svn', '.github', '.diffex', '.pi', '.ssh', '.aws', '.gnupg', '.cache', '__pycache__', + '.vscode', '.idea', '.npm', '.pnpm-store', '.yarn', '.turbo', '.artifacts', 'workspaces', 'workspace', + 'auth.json', 'credentials.json', 'credentials', 'id_rsa', 'id_dsa', 'id_ed25519', '.npmrc', '.netrc', '.env', '.ds_store', 'npm-debug.log'} + + +def fail(name, reason): + raise ValueError(f'{name}: {reason}') + + +def local_path(value): + """Confine CLI paths to the workspace, account home, or temporary storage.""" + value = Path(value) + if '..' in value.parts: + raise ValueError('path traversal is not permitted') + temporary = [tempfile.gettempdir(), '/tmp'] + if sys.platform == 'darwin': + native = os.confstr(65537) # _CS_DARWIN_USER_TEMP_DIR, independent of TMPDIR. + if not native: + raise ValueError('missing OS user temporary directory') + metadata = os.stat(os.path.realpath(native)) + if (not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid() or + stat.S_IMODE(metadata.st_mode) & 0o077): + raise ValueError('OS user temporary directory is not owner-private') + temporary.append(native) + roots = {os.path.abspath(root) for root in + (os.getcwd(), pwd.getpwuid(os.getuid()).pw_dir, *temporary)} + roots.discard(os.path.sep) + roots.update(os.path.realpath(root) for root in tuple(roots)) + absolute = os.path.abspath(value) + if not absolute.startswith(tuple(root + os.path.sep for root in roots)): + raise ValueError('path outside local artifact roots') + scope = max((root for root in roots if absolute.startswith(root + os.path.sep)), key=len) + canonical_scope = os.path.realpath(scope) + canonical = os.path.realpath(absolute) + if not canonical.startswith(canonical_scope + os.path.sep): + raise ValueError('path outside local artifact root') + if canonical != os.path.join(canonical_scope, os.path.relpath(absolute, scope)): + raise ValueError('local artifact path traverses a symlink') + return Path(canonical) + + +def contained_path(root, name): + """Check the actual destination, not only the spelling of a tar member.""" + absolute = os.path.abspath(os.path.join(root, name)) + if not absolute.startswith(str(root) + os.path.sep): + raise ValueError('path outside payload root') + canonical = os.path.realpath(absolute) + if not canonical.startswith(str(root) + os.path.sep): + raise ValueError('path outside payload root') + if canonical != absolute: + raise ValueError('payload path traverses a symlink') + return Path(canonical) + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def safe_name(name): + p = PurePosixPath(name) + if not name or name.startswith('/') or '\\' in name or ':' in name or str(p) != name.rstrip('/') or any(x in {'.', '..'} for x in p.parts) or any(ord(c) < 32 or ord(c) == 127 for c in name): + fail(name, 'unsafe archive path') + return name.rstrip('/') + + +def link_target(name, target): + if not target or target.startswith('/') or '\\' in target or ':' in target or any(ord(c) < 32 for c in target): + fail(name, 'unsafe link target') + parts = list(PurePosixPath(name).parent.parts) + for part in target.split('/'): + if part == '..': + if len(parts) <= 1: + fail(name, 'escaping link target') + parts.pop() + elif part not in {'', '.'}: + parts.append(part) + return '/'.join(parts) + + +def read_archive(file, root): + file = local_path(file) + if safe_name(root) != root or len(PurePosixPath(root).parts) != 1: + fail(root, 'expected a single archive root directory') + if file.stat().st_size > MAX_BYTES: + fail(file, 'archive exceeds 512 MiB') + entries = {} + folded = set() + total = 0 + # Bound decompression before tarfile sees PAX/GNU headers: those can otherwise + # allocate arbitrarily large metadata before yielding the first member. + with gzip.open(file, 'rb') as zipped: + expanded = zipped.read(MAX_BYTES + 1) + if len(expanded) > MAX_BYTES: + fail(file, 'archive expansion exceeds 512 MiB') + with tarfile.open(fileobj=io.BytesIO(expanded), mode='r:') as archive: + for item in archive: + name = safe_name(item.name) + if name != root and not name.startswith(root + '/'): + fail(name, 'member outside archive root') + if name.casefold() in folded: + fail(name, 'duplicate or case-colliding archive path') + folded.add(name.casefold()) + if not (item.isfile() or item.isdir() or item.issym()): + fail(name, 'hard links and special files are prohibited') + if item.mode & 0o7000: + fail(name, 'special permission bits are prohibited') + if item.size < 0: + fail(name, 'negative member size') + total += item.size + if total > MAX_BYTES or len(entries) >= MAX_FILES: + fail(file, 'archive expansion/resource limit exceeded') + if item.issym(): + link_target(name, item.linkname) + value = ('link', item.linkname, 0o777) + elif item.isdir(): + value = ('dir', None, 0o755) + else: + value = ('file', archive.extractfile(item).read(), 0o755 if item.mode & 0o111 else 0o644) + entries[name] = value + if any(expanded[archive.offset:]): + fail(file, 'nonzero trailing archive data') + validate_links(entries) + return entries + + +def validate_links(entries): + for name in entries: + parts = PurePosixPath(name).parts + for i in range(1, len(parts)): + ancestor = '/'.join(parts[:i]) + if ancestor in entries and entries[ancestor][0] != 'dir': + fail(name, 'member below a link or non-directory') + for name, (kind, value, _) in entries.items(): + if kind != 'link': + continue + current = link_target(name, value) + seen = {name} + # Resolve links in every component, not only the terminal path. + for _ in range(128): + parts = current.split('/') + changed = False + for i in range(1, len(parts) + 1): + prefix = '/'.join(parts[:i]) + entry = entries.get(prefix) + if entry and entry[0] == 'link': + if prefix in seen: + fail(name, 'cyclic link') + seen.add(prefix) + current = '/'.join([link_target(prefix, entry[1]), *parts[i:]]) + changed = True + break + if not changed: + if current not in entries and not any(p.startswith(current + '/') for p in entries): + fail(name, 'dangling link') + break + else: + fail(name, 'link resolution limit exceeded') + + +def private_path(name): + for part in PurePosixPath(name).parts: + lower = part.lower() + if lower in PRIVATE or lower.startswith(('.env.', '._', '.npmrc.', '.netrc.')) or lower.endswith(('.pem', '.key', '.p12', '.pfx')): + fail(name, 'prohibited credential/config/cache/workspace/development record') + if re.search(r'(^|/)(?:AGENTS|DIFFEX|CLAUDE)\.md$', name, re.I) and name != 'diffex/' + CLI + 'dist/AGENTS.md': + fail(name, 'prohibited repository instructions') + if '/docs/release/' in name or '/private-debug/' in name: + fail(name, 'prohibited development record') + + +def inventory(entries): + files = [] + for name, (kind, value, mode) in sorted(entries.items()): + if kind == 'dir' or name == 'diffex/distribution-files.json': + continue + item = {'path': name[len('diffex/'):], 'type': kind, 'mode': mode} + if kind == 'link': + item['target'] = value + else: + item.update(size=len(value), sha256=sha(value)) + files.append(item) + return {'schemaVersion': 1, 'algorithm': 'sha256', 'files': files} + + +def get_json(entries, path): + entry = entries.get('diffex/' + path) + if not entry or entry[0] != 'file': + fail(path, 'missing JSON metadata') + return json.loads(entry[1]) + + +def required(entries, path): + entry = entries.get('diffex/' + path) + if not entry or entry[0] != 'file' or not entry[1].strip(): + fail(path, 'missing required file') + return entry[1] + + +def validate_installed(entries, metadata): + lock = get_json(entries, 'app/package-lock.json') + package = get_json(entries, 'app/package.json') + packages = lock.get('packages', {}) + if lock.get('lockfileVersion') != 3 or package.get('dependencies') != {'@diffexai/diffex': metadata['version']}: + fail('app', 'install lock/root identity mismatch') + original = get_json(entries, CLI + 'release/install-package-lock.json') + expected = json.loads(json.dumps(original)) + expected['packages']['node_modules/@diffexai/diffex']['integrity'] = metadata.get('application', {}).get('integrity') + if lock != expected or package != get_json(entries, CLI + 'release/install-package.json'): + fail('app', 'installed lock differs from retained generated install lock') + platform, arch = metadata['platform'].split('-') + + def supported(entry): + for key, value in [('os', platform), ('cpu', arch), ('libc', 'glibc')]: + values = entry.get(key, []) + if isinstance(values, str): + values = [values] + positive = [v for v in values if not v.startswith('!')] + if '!' + value in values or (positive and value not in positive and 'any' not in positive): + return False + return True + + actual = {} + for name, (kind, value, _) in entries.items(): + local = name.removeprefix('diffex/app/') + if kind != 'file' or not local.startswith('node_modules/') or not local.endswith('/package.json'): + continue + package_path = local[:-len('/package.json')] + # Only npm installation locations, not source fixtures or bundled dist/node_modules. + if not re.fullmatch(r'node_modules/(?:@[^/]+/)?[^/]+(?:/node_modules/(?:@[^/]+/)?[^/]+)*', package_path): + continue + if package_path not in packages: + fail(name, 'installed package absent from reviewed lock') + info = json.loads(value) + expected = packages[package_path] + pkg_name = package_path.rsplit('node_modules/', 1)[1] + if info.get('name') != pkg_name or info.get('version') != expected.get('version') or not supported(expected): + fail(name, 'installed package identity differs from reviewed lock/target') + actual[package_path] = info + for path, entry in packages.items(): + if not path: + continue + safe_name(path) + if not path.startswith('node_modules/') or entry.get('link') or entry.get('dev') or entry.get('devOptional') or entry.get('extraneous'): + fail(path, 'invalid reviewed production lock entry') + if supported(entry) and path not in actual: + fail(path, 'missing production dependency (including supported optional dependencies)') + if path != 'node_modules/@diffexai/diffex' and (not entry.get('integrity', '').startswith('sha512-') or not entry.get('resolved', '').startswith('https://registry.npmjs.org/')): + fail(path, 'unreviewed external package identity') + for name, (kind, _, _) in entries.items(): + prefix = 'diffex/app/' + if not name.startswith(prefix) or kind == 'dir': + continue + local = name[len(prefix):] + if local in {'package.json', 'package-lock.json'}: + continue + if local.startswith('node_modules/.bin/') and kind == 'link': + continue + owner = re.match(r'^(node_modules/(?:@[^/]+/)?[^/]+(?:/node_modules/(?:@[^/]+/)?[^/]+)*)(?:/|$)', local) + if not owner or owner[1] not in actual: + fail(name, 'unattributed application file outside locked production packages') + return [{'path': p, 'name': v['name'], 'version': v['version'], 'integrity': packages[p].get('integrity')} for p, v in sorted(actual.items())] + + +def inspect_entries(entries, license_file): + license_file = local_path(license_file) + for name in entries: + private_path(name) + local = name.removeprefix('diffex/') + if local != 'diffex' and not (local.startswith(('bin/', 'runtime/', 'app/')) or local in { + 'bin', 'runtime', 'app', 'distribution.json', 'distribution-files.json', 'distribution-components.json', 'LICENSE', 'THIRD-PARTY-NOTICES.txt'}): + fail(name, 'unapproved top-level payload') + metadata = get_json(entries, 'distribution.json') + if metadata.get('schemaVersion') != 1 or metadata.get('product') != '@diffexai/diffex' or metadata.get('platform') not in {'darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'} or not re.fullmatch(r'\d+\.\d+\.\d+', metadata.get('version', '')) or not re.fullmatch('[a-f0-9]{40}', metadata.get('sourceCommit', '')): + fail('distribution.json', 'invalid distribution identity') + if get_json(entries, 'distribution-files.json') != inventory(entries): + fail('distribution-files.json', 'payload inventory mismatch') + if required(entries, 'LICENSE') != Path(license_file).read_bytes(): + fail('LICENSE', 'license text differs from reviewed root license') + for path in ['bin/diffex', 'runtime/bin/node', 'runtime/LICENSE', 'runtime/lib/node_modules/npm/bin/npm-cli.js', + 'THIRD-PARTY-NOTICES.txt', CLI + 'dist/cli.js']: + required(entries, path) + for name, (kind, _, _) in entries.items(): + local = name.removeprefix('diffex/') + if kind == 'dir': + continue + if local.startswith('bin/') and local != 'bin/diffex': + fail(name, 'unapproved executable') + if local.startswith('runtime/') and local not in {'runtime/bin/node', 'runtime/LICENSE', 'runtime/release-key.asc', 'runtime/SHASUMS256.txt', 'runtime/SHASUMS256.txt.sig'} and not local.startswith('runtime/lib/node_modules/npm/'): + fail(name, 'unapproved private runtime payload') + runtime = metadata.get('runtime', {}) + if not re.fullmatch(r'22\.\d+\.\d+', runtime.get('nodeVersion', '')) or runtime.get('filename') != f"node-v{runtime['nodeVersion']}-{metadata['platform']}.tar.gz" or runtime.get('url') != f"https://nodejs.org/dist/v{runtime['nodeVersion']}/{runtime['filename']}": + fail('runtime', 'unapproved runtime identity/URL') + if metadata.get('archiveUrl') != f"https://github.com/diffexai/diffex/releases/download/v{metadata['version']}/diffex-{metadata['version']}-{metadata['platform']}.tar.gz": + fail('distribution.json', 'unapproved archive URL') + for path, field in [('runtime/SHASUMS256.txt', 'checksumsSha256'), ('runtime/SHASUMS256.txt.sig', 'signatureSha256'), ('runtime/release-key.asc', 'releaseKeySha256'), ('runtime/bin/node', 'nodeSha256')]: + if sha(required(entries, path)) != runtime.get(field): + fail(path, 'official runtime verification proof mismatch') + for path in ['bin/diffex', 'runtime/bin/node']: + if entries['diffex/' + path][2] != 0o755: + fail(path, 'required executable permission missing') + npm = get_json(entries, 'runtime/lib/node_modules/npm/package.json') + if npm.get('name') != 'npm' or npm.get('version') != metadata.get('runtime', {}).get('npmVersion'): + fail('runtime', 'private npm identity mismatch') + components = get_json(entries, 'distribution-components.json') + if not isinstance(components, list) or not components: + fail('distribution-components.json', 'missing third-party component inventory') + for component in components: + notice = component.get('notice') + if notice and (safe_name(notice) != notice or sha(required(entries, notice)) != component.get('noticeSha256')): + fail(str(notice), 'component notice mismatch') + for legal in component.get('legalFiles', []): + if safe_name(legal['path']) != legal['path'] or sha(required(entries, legal['path'])) != legal['sha256']: + fail(legal['path'], 'upstream legal file mismatch') + installed = validate_installed(entries, metadata) + if metadata.get('packages') != installed: + fail('distribution.json', 'installed package identity inventory mismatch') + # Reconstruct only the Diffex-owned package for the existing strict gate. + # npm-installed nested dependencies are independently checked against the lock. + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode='w') as archive: + for name, (kind, value, _) in entries.items(): + prefix = 'diffex/' + CLI + if not name.startswith(prefix): + continue + local = name[len(prefix):] + if local.startswith('node_modules/') or not local or kind == 'dir': + continue + if kind != 'file': + fail(name, 'links in Diffex-owned payload are prohibited') + item = tarfile.TarInfo('package/' + local) + item.size = len(value) + archive.addfile(item, io.BytesIO(value)) + # Existing checker expects an on-disk file and verifies legal/docs/runtime hashes. + with tempfile.TemporaryDirectory(prefix='diffex-homebrew-owned-') as temporary: + candidate = Path(temporary, 'package.tgz') + candidate.write_bytes(stream.getvalue()) + inspected = strict.inspect_archive(candidate, license_file, '@diffexai/diffex', metadata['version']) + if get_json(entries, CLI + 'distribution-files.json').get('sourceCommit') != metadata['sourceCommit']: + fail('distribution.json', 'Diffex source commit differs from archive identity') + if metadata.get('payloadIdentity') != sha(json.dumps(inspected['payloadFiles'], separators=(',', ':'), sort_keys=True).encode()): + fail('distribution.json', 'Diffex payload identity mismatch') + return {'version': metadata['version'], 'sourceCommit': metadata['sourceCommit'], 'platform': metadata['platform'], + 'nodeVersion': metadata['runtime']['nodeVersion'], 'npmVersion': metadata['runtime']['npmVersion'], + 'inventorySha256': sha(required(entries, 'distribution-files.json'))} + + +def tree_entries(root): + root = local_path(root) + entries = {'diffex': ('dir', None, 0o755)} + total = 0 + for directory, dirs, files in os.walk(root, followlinks=False): + for filename in sorted(dirs + files): + # Keep the final symlink itself for inventory, without dereferencing it. + absolute = os.path.abspath(os.path.join(directory, filename)) + if not absolute.startswith(str(root) + os.path.sep): + raise ValueError('path outside payload root') + path = Path(absolute) + name = 'diffex/' + path.relative_to(root).as_posix() + safe_name(name) + mode = path.lstat().st_mode + if stat.S_ISLNK(mode): + entries[name] = ('link', os.readlink(path), 0o777) + elif stat.S_ISDIR(mode): + entries[name] = ('dir', None, 0o755) + elif stat.S_ISREG(mode): + path = contained_path(root, path) + total += path.stat().st_size + if total > MAX_BYTES: + fail(name, 'payload exceeds 512 MiB') + entries[name] = ('file', path.read_bytes(), 0o755 if mode & 0o111 else 0o644) + else: + fail(name, 'special file prohibited') + if len(entries) > MAX_FILES: + fail(name, 'file count limit exceeded') + validate_links(entries) + return entries + + +def prepare(root, output, license_file): + root = local_path(root) + output = local_path(output) + license_file = local_path(license_file) + entries = tree_entries(root) + data = (json.dumps(inventory(entries), sort_keys=True, separators=(',', ':')) + '\n').encode() + contained_path(root, 'distribution-files.json').write_bytes(data) + entries['diffex/distribution-files.json'] = ('file', data, 0o644) + inspected = inspect_entries(entries, license_file) + with open(output, 'xb') as file: + with gzip.GzipFile(filename='', mode='wb', fileobj=file, mtime=0, compresslevel=9) as zipped: + with tarfile.open(fileobj=zipped, mode='w', format=tarfile.GNU_FORMAT) as archive: + for name, (kind, value, mode) in sorted(entries.items()): + item = tarfile.TarInfo(name) + item.mode = mode + item.mtime = 0 + if kind == 'dir': + item.type = tarfile.DIRTYPE + elif kind == 'link': + item.type = tarfile.SYMTYPE + item.linkname = value + else: + item.size = len(value) + archive.addfile(item, io.BytesIO(value) if kind == 'file' else None) + if Path(output).stat().st_size > MAX_BYTES: + fail(output, 'archive exceeds 512 MiB') + return inspected + + +def extract(file, output, root): + target = local_path(output) + entries = read_archive(file, root) + if target.exists(): + fail(output, 'safe extraction requires a new directory') + target.mkdir(parents=True) + # Full validation completed before the first extraction write. Links last. + for kind_wanted in ['dir', 'file', 'link']: + for name, (kind, value, mode) in sorted(entries.items()): + if kind != kind_wanted: + continue + path = contained_path(target, name) + path.parent.mkdir(parents=True, exist_ok=True) + if kind == 'dir': + path.mkdir(exist_ok=True) + elif kind == 'file': + with path.open('xb') as stream: + stream.write(value) + path.chmod(mode) + else: + path.symlink_to(value) + return {'files': len(entries)} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('command', choices=['inspect', 'prepare', 'extract', 'validate']) + parser.add_argument('input') + parser.add_argument('output', nargs='?') + parser.add_argument('--root', default='diffex') + parser.add_argument('--license', default=str(Path(__file__).resolve().parent.parent / 'LICENSE')) + args = parser.parse_args() + try: + if args.command == 'validate': + result = {'files': len(read_archive(args.input, args.root))} + elif args.command == 'extract': + result = extract(args.input, args.output, args.root) + elif args.command == 'prepare': + result = prepare(args.input, args.output, args.license) + else: + result = inspect_entries(read_archive(args.input, 'diffex'), args.license) + print(json.dumps(result, sort_keys=True)) + except (ValueError, OSError, EOFError, KeyError, TypeError, AttributeError, tarfile.TarError) as error: + parser.exit(1, str(error) + '\n') diff --git a/scripts/checkout-approved-release.mjs b/scripts/checkout-approved-release.mjs new file mode 100644 index 000000000..8b4c2e680 --- /dev/null +++ b/scripts/checkout-approved-release.mjs @@ -0,0 +1,32 @@ +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { assertCanonicalOrigin, git, isStableVersion } from "./release-source.mjs"; + +/** Revalidate local, fully fetched refs before replacing trusted workflow control code. */ +export function checkoutApprovedRelease(tag, expectedCommit) { + assertCanonicalOrigin(); + if (!tag?.startsWith("v") || !isStableVersion(tag.slice(1)) || !/^[a-f0-9]{40}$/.test(expectedCommit ?? "")) + throw new Error("Approved release checkout requires a stable tag and full commit identity"); + if (git("status", "--porcelain", "--untracked-files=all")) + throw new Error("Approved release checkout requires a clean control checkout"); + const tagged = git("rev-parse", `refs/tags/${tag}^{commit}`); + if (tagged !== expectedCommit) throw new Error("Release tag differs from the previously approved source"); + git("merge-base", "--is-ancestor", tagged, "refs/remotes/origin/main"); + let packages = 0; + for (const path of git("ls-tree", "-r", "--name-only", tagged, "packages").split("\n")) { + if (!path.endsWith("/package.json")) continue; + const manifest = JSON.parse(git("show", `${tagged}:${path}`)); + if (manifest.private === true) continue; + packages++; + if (manifest.version !== tag.slice(1)) throw new Error("Approved release package/tag version mismatch"); + } + if (!packages) throw new Error("Approved release has no public packages"); + // Only the exact tagged commit, independently proven to belong to canonical + // main, can execute in this job. No arbitrary dispatch ref is checked out. + git("checkout", "--detach", tagged); + return tagged; +} + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + console.log(`Checked out approved release ${checkoutApprovedRelease(process.env.RELEASE_TAG, process.env.DIFFEX_SOURCE_COMMIT)}`); +} diff --git a/scripts/checkout-approved-release.test.mjs b/scripts/checkout-approved-release.test.mjs new file mode 100644 index 000000000..ee412a332 --- /dev/null +++ b/scripts/checkout-approved-release.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const script = fileURLToPath(new URL("./checkout-approved-release.mjs", import.meta.url)); +function fixture() { + const directory = mkdtempSync(join(tmpdir(), "diffex-approved-checkout-")); + const env = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "Fixture", GIT_AUTHOR_EMAIL: "fixture@example.invalid", + GIT_COMMITTER_NAME: "Fixture", GIT_COMMITTER_EMAIL: "fixture@example.invalid" }; + const git = (...args) => execFileSync("git", args, { cwd: directory, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + git("init", "--initial-branch=main"); + git("remote", "add", "origin", "https://github.com/diffexai/diffex.git"); + mkdirSync(join(directory, "packages/cli"), { recursive: true }); + writeFileSync(join(directory, "packages/cli/package.json"), '{"name":"@diffexai/diffex","version":"1.2.3"}'); + git("add", "packages/cli/package.json"); git("commit", "-m", "release"); + const release = git("rev-parse", "HEAD"); git("tag", "v1.2.3"); + writeFileSync(join(directory, "control.txt"), "trusted workflow control"); + git("add", "control.txt"); git("commit", "-m", "newer workflow control"); + const control = git("rev-parse", "HEAD"); + git("update-ref", "refs/remotes/origin/main", control); + return { directory, git, release, control, + run: (overrides = {}) => spawnSync(process.execPath, [script], { cwd: directory, + env: { ...env, RELEASE_TAG: "v1.2.3", DIFFEX_SOURCE_COMMIT: release, ...overrides }, encoding: "utf8", timeout: 10_000 }), + cleanup: () => rmSync(directory, { recursive: true, force: true }) }; +} + +test("recovery starts from trusted workflow control and checks out only the verified tagged main ancestor", () => { + const f = fixture(); + try { + const result = f.run(); + assert.equal(result.status, 0, result.stderr); + assert.equal(f.git("rev-parse", "HEAD"), f.release); + assert.equal(f.git("branch", "--show-current"), ""); + assert.equal(f.git("rev-parse", "refs/remotes/origin/main"), f.control); + assert.equal(JSON.parse(readFileSync(join(f.directory, "packages/cli/package.json"), "utf8")).version, "1.2.3"); + } finally { f.cleanup(); } +}); + +test("invalid, divergent, unapproved and dirty recovery source cannot replace control code", () => { + for (const setup of [ + (f) => ({ RELEASE_TAG: "refs/heads/attacker" }), + (f) => ({ DIFFEX_SOURCE_COMMIT: "--detach" }), + (f) => ({ DIFFEX_SOURCE_COMMIT: "b".repeat(40) }), + (f) => { f.git("remote", "set-url", "origin", "https://github.com/other/diffex.git"); return {}; }, + (f) => { writeFileSync(join(f.directory, "unreviewed"), "dirty"); return {}; }, + (f) => { + const unapproved = f.git("commit-tree", "HEAD^{tree}", "-p", "HEAD", "-m", "not on main"); + f.git("update-ref", "refs/tags/v1.2.3", unapproved); + return { DIFFEX_SOURCE_COMMIT: unapproved }; + }, + (f) => { f.git("tag", "v1.2.4"); return { RELEASE_TAG: "v1.2.4", DIFFEX_SOURCE_COMMIT: f.control }; }, + ]) { + const f = fixture(); + try { + const result = f.run(setup(f)); + assert.notEqual(result.status, 0); + assert.equal(f.git("rev-parse", "HEAD"), f.control, "Rejected source changed the checkout"); + } finally { f.cleanup(); } + } +}); diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 0e55c5360..fe89d795c 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -17,7 +17,7 @@ test("script-test jobs provision exact pnpm before exercising real package-manag const release = parse(readFileSync(new URL("../.github/workflows/build-binaries.yml", import.meta.url), "utf8")); for (const job of [workflow.jobs["workspace-tests"], release.jobs["prepare-artifacts"]]) { const setup = job.steps.findIndex((step) => step.run === "npm install --global --ignore-scripts pnpm@10.28.2"); - const tests = job.steps.findIndex((step) => step.run?.includes("./test.sh")); + const tests = job.steps.findIndex((step) => step.run?.includes("./scripts/test.sh")); assert(setup !== -1 && tests > setup, "Pinned pnpm must be available before the real fixture tests"); } assert.equal(release.jobs["prepare-artifacts"].steps.find((step) => step.run === "npm install --global --ignore-scripts pnpm@10.28.2").if, @@ -30,7 +30,7 @@ test("script-test jobs provision exact Bun before exercising its installation fi const release = parse(readFileSync(new URL("../.github/workflows/build-binaries.yml", import.meta.url), "utf8")); for (const job of [workflow.jobs["workspace-tests"], release.jobs["prepare-artifacts"]]) { const setup = job.steps.findIndex((step) => step.uses === "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); - const tests = job.steps.findIndex((step) => step.run?.includes("./test.sh")); + const tests = job.steps.findIndex((step) => step.run?.includes("./scripts/test.sh")); assert(setup !== -1 && tests > setup, "Pinned Bun must be available before the real fixture tests"); assert.equal(job.steps[setup].with["bun-version"], "1.3.11"); } @@ -46,6 +46,6 @@ test("the CI aggregate requires build checks and every isolated test shard", () assert.deepEqual(jobs["workspace-tests"].strategy.matrix.shard, [1, 2]); assert.deepEqual(jobs["coding-agent"].strategy.matrix.shard, [1, 2, 3, 4]); for (const job of ["workspace-tests", "coding-agent"]) { - assert.ok(jobs[job].steps.some((step) => step.run?.startsWith("./test.sh "))); + assert.ok(jobs[job].steps.some((step) => step.run?.startsWith("./scripts/test.sh "))); } }); diff --git a/scripts/cli-distribution.mjs b/scripts/cli-distribution.mjs index 61030d27b..3aaaff35b 100644 --- a/scripts/cli-distribution.mjs +++ b/scripts/cli-distribution.mjs @@ -71,7 +71,7 @@ export function cliDistribution(root = ".") { visit("@diffexai/diffex-evolution"); visit("@diffexai/diffex-harness-trace"); bundled.delete(cli.manifest.name); - const policy = JSON.parse(readFileSync(join(root, "docs/release/distribution-policy.json"), "utf8")); + const policy = JSON.parse(readFileSync(join(root, "scripts/distribution-policy.json"), "utf8")); const actual = [...bundled.values()] .map((pkg) => relative(join(root, "packages"), pkg.directory).replaceAll("\\", "/")) .sort(); diff --git a/scripts/cli-distribution.test.mjs b/scripts/cli-distribution.test.mjs index d00969d23..918ddfa4d 100644 --- a/scripts/cli-distribution.test.mjs +++ b/scripts/cli-distribution.test.mjs @@ -49,7 +49,7 @@ test("an unreviewed private dependency cannot silently enter the public CLI tarb mkdirSync(join(directory, path, ".."), { recursive: true }); writeFileSync(join(directory, path), JSON.stringify(value)); }; - write("docs/release/distribution-policy.json", { + write("scripts/distribution-policy.json", { status: "approved", bundledWorkspaces: ["evolution", "harness-trace"], }); diff --git a/scripts/daytona-behaviors-workflow.test.mjs b/scripts/daytona-behaviors-workflow.test.mjs index ce5a6fd17..90a7271f1 100644 --- a/scripts/daytona-behaviors-workflow.test.mjs +++ b/scripts/daytona-behaviors-workflow.test.mjs @@ -91,7 +91,7 @@ test("one 35-minute preparation freezes, builds offline, prepares and verifies t assert.equal(run.env.OPENAI_API_KEY !== undefined, mode === "live"); assert.equal(job.steps.at(-1).if, "${{ always() && steps.sanitize.outputs.safe_to_upload == 'true' }}"); } - assert.deepEqual(workflow.jobs["daytona-behaviors"].needs, ["prepare", "deterministic", "live"]); + assert.deepEqual(workflow.jobs["daytona-behaviors"].needs, ["prepare", "deterministic", "live", "homebrew"]); }); test("Daytona npm behaviors enable scripts while Bun and ordinary artifact consumers remain disabled", () => { @@ -140,13 +140,42 @@ test("only a completed all-files credential guard permits sanitized evidence pub } }); -for (const scenario of ["pass", "missing-artifact", "missing-result", "invalid-result", "missing-xml", "failed-job", "sanitizer-failed", "missing-safety-report", "unsafe-report", "credential-found", "cleanup-failed", "wrong-commit", "wrong-run", "orchestration-failed", "cleanup-unconfirmed", "partial-scenarios", "wrong-result-commit", "different-tarballs"]) { +test("Homebrew qualification and dispatch use retained native bytes and an isolated snapshot", () => { + assert.equal(workflow.on.workflow_dispatch.inputs.homebrew_candidate.type, "boolean"); + assert.equal(workflow.on.workflow_dispatch.inputs.homebrew_candidate.default, false); + const prepare = workflow.jobs["prepare-homebrew"]; + assert.ok(prepare.if.includes("vars.HOMEBREW_QUALIFIED == 'true'")); + assert.ok(prepare.if.includes("inputs.homebrew_candidate")); + assert.equal(prepare.needs, "prepare"); + const commands = prepare.steps.map((step) => step.run ?? "").join("\n"); + assert.match(commands, /prepare-homebrew-archive\.mjs \.artifacts\/release \.artifacts\/daytona-homebrew-candidate/); + assert.doesNotMatch(commands, /npm run build|release-artifacts\.mjs (prepare|freeze)/); + assert.match(commands, /scripts\/daytona-behaviors\/homebrew\.py/); + assert.match(commands, /scripts\/fixtures\/homebrew-runtime-operations\.mjs/); + const upload = prepare.steps.find((step) => step.with?.name?.startsWith("daytona-homebrew-candidate-")); + assert.equal(upload.with.path, ".artifacts/daytona-homebrew-candidate/*.tar.gz\n.artifacts/daytona-homebrew-candidate/*.json\n"); + assert.doesNotMatch(upload.with.path, /release\/|source/); + const job = workflow.jobs.homebrew; + assert.equal(job.needs, "prepare-homebrew"); + assert.equal(job.env.DAYTONA_SNAPSHOT, "${{ vars.DAYTONA_HOMEBREW_SNAPSHOT }}"); + assert.equal(job.environment, "daytona-behaviors"); + const run = job.steps.find((step) => step.id === "run"); + assert.match(run.run, /--mode homebrew/); + assert.equal(run.env.OPENAI_API_KEY, undefined); + assert.match(job.steps.find((step) => step.id === "recover").run, /daytona_runner\.py recover/); + assert.match(job.steps.find((step) => step.id === "sanitize").run, /--mode deterministic/); + assert.equal(job.steps.at(-1).if, "${{ always() && steps.sanitize.outputs.safe_to_upload == 'true' }}"); + assert.equal(job.steps.at(-1).with.path, ".artifacts/daytona-homebrew-sanitized/"); + for (const step of job.steps) assert.equal(step.env?.OPENAI_API_KEY, undefined); +}); + +for (const scenario of ["homebrew-pass", "homebrew-missing-artifact", "homebrew-failed-job", "homebrew-cleanup-unconfirmed", "homebrew-partial-scenarios", "homebrew-credential-found", "pass", "missing-artifact", "missing-result", "invalid-result", "missing-xml", "failed-job", "sanitizer-failed", "missing-safety-report", "unsafe-report", "credential-found", "cleanup-failed", "wrong-commit", "wrong-run", "orchestration-failed", "cleanup-unconfirmed", "partial-scenarios", "wrong-result-commit", "different-tarballs"]) { test(`aggregate fails closed: ${scenario}`, () => { const root = mkdtempSync(join(tmpdir(), "daytona workflow ")); try { const commit = "a".repeat(40); const fixtures = join(root, "fixtures"); - for (const mode of ["deterministic", "live"]) { + for (const mode of ["deterministic", "live", "homebrew"]) { const directory = join(fixtures, mode); mkdirSync(join(directory, "evidence"), { recursive: true }); writeFileSync(join(directory, "publication-safety.json"), JSON.stringify({ @@ -160,7 +189,9 @@ for (const scenario of ["pass", "missing-artifact", "missing-result", "invalid-r commit, mode, run_id: `123-2-${mode}`, outcome: "passed", execution: "passed", exit_code: 0, installed_outcome: "passed", reports_complete: true, cleanup: "confirmed", })); - const identities = mode === "live" + const identities = mode === "homebrew" + ? ["homebrew-formula-install", "homebrew-retained-archive", "homebrew-installation", "homebrew-print", "homebrew-builtin-auth-transport", "homebrew-terminal-tools-cancel-resume", "homebrew-formula-upgrade-retention", "homebrew-formula-uninstall-retention"] + : mode === "live" ? ["artifact-integrity", "npm-package-integrity", "npm-live-openai"] : ["artifact-integrity", "npm-global-installation", "npm-package-integrity", "bun-package-integrity", ...["npm", "bun"].flatMap((manager) => ["installation", "print", "builtin-auth-transport", "terminal-tools-cancel-resume", "artifact-terminal"].map((name) => `${manager}-${name}`))]; @@ -170,6 +201,23 @@ for (const scenario of ["pass", "missing-artifact", "missing-result", "invalid-r })); for (const file of ["orchestration.xml", "evidence/results.xml"]) writeFileSync(join(directory, file), ""); } + const homebrew = join(fixtures, "homebrew"); + if (scenario === "homebrew-missing-artifact") rmSync(homebrew, { recursive: true }); + for (const [fault, file, key, value] of [ + ["homebrew-cleanup-unconfirmed", "orchestration.json", "cleanup", "unconfirmed"], + ["homebrew-credential-found", "publication-safety.json", "credential_matches", 1], + ]) { + if (scenario === fault) { + const report = JSON.parse(readFileSync(join(homebrew, file), "utf8")); + report[key] = value; + writeFileSync(join(homebrew, file), JSON.stringify(report)); + } + } + if (scenario === "homebrew-partial-scenarios") { + const report = JSON.parse(readFileSync(join(homebrew, "evidence/results.json"), "utf8")); + report.scenarios.pop(); + writeFileSync(join(homebrew, "evidence/results.json"), JSON.stringify(report)); + } const live = join(fixtures, "live"); if (scenario === "missing-artifact") rmSync(live, { recursive: true }); if (scenario === "missing-safety-report") rmSync(join(live, "publication-safety.json")); @@ -211,7 +259,7 @@ const { cpSync, existsSync } = require("node:fs"); const { join } = require("node:path"); const args = process.argv.slice(2); const name = args[args.indexOf("--name") + 1]; -const mode = name.includes("-deterministic-") ? "deterministic" : "live"; +const mode = name.includes("-homebrew-") ? "homebrew" : name.includes("-deterministic-") ? "deterministic" : "live"; const source = join(process.env.DAYTONA_FIXTURES, mode); if (!existsSync(source)) process.exit(1); cpSync(source, args[args.indexOf("--dir") + 1], { recursive: true }); @@ -229,14 +277,16 @@ cpSync(source, args[args.indexOf("--dir") + 1], { recursive: true }); GITHUB_RUN_ATTEMPT: "2", GITHUB_REPOSITORY: "diffexai/diffex", GITHUB_STEP_SUMMARY: join(root, "summary.md"), + HOMEBREW_REQUIRED: scenario.startsWith("homebrew-") ? "true" : "false", + HOMEBREW_RESULT: scenario === "homebrew-failed-job" ? "failure" : "success", PREPARE_RESULT: "success", DETERMINISTIC_RESULT: "success", LIVE_RESULT: ["failed-job", "sanitizer-failed"].includes(scenario) ? "failure" : "success", }, }); - assert.equal(result.status, scenario === "pass" ? 0 : 1, `${result.stdout}\n${result.stderr}`); + assert.equal(result.status, ["pass", "homebrew-pass"].includes(scenario) ? 0 : 1, `${result.stdout}\n${result.stderr}`); const summary = JSON.parse(readFileSync(join(root, ".artifacts/daytona-summary/summary.json"), "utf8")); - assert.equal(summary.passed, scenario === "pass"); + assert.equal(summary.passed, ["pass", "homebrew-pass"].includes(scenario)); // No evidence JSON (including provider response fields) is echoed into job logs. assert.doesNotMatch(result.stdout, /artifactHashes|diffex\.tgz|credential_matches|schemaVersion/); // Downloaded evidence, including hidden digest members, survives failed cases. diff --git a/scripts/daytona-behaviors/daytona_runner.py b/scripts/daytona-behaviors/daytona_runner.py index 38555cf6b..34732719c 100644 --- a/scripts/daytona-behaviors/daytona_runner.py +++ b/scripts/daytona-behaviors/daytona_runner.py @@ -274,7 +274,8 @@ def stage(self, sandbox: Sandbox, artifacts: Path, bundle: Path) -> None: self.execute(sandbox, f"test -f {INSTALLED} && mkdir -p {REMOTE_EVIDENCE}") def launch(self, sandbox: Sandbox, live_key: str | None) -> None: - argv = ["python3", INSTALLED, "--artifacts", REMOTE_RELEASE, + entrypoint = f"{REMOTE_BUNDLE}/scripts/daytona-behaviors/homebrew.py" if self.state.mode == "homebrew" else INSTALLED + argv = ["python3", entrypoint, "--artifacts", REMOTE_RELEASE, "--output", REMOTE_EVIDENCE, "--mode", self.state.mode, "--commit", self.state.commit] script = ( @@ -595,7 +596,7 @@ def main(argv: list[str] | None = None) -> int: run.add_argument("--snapshot", required=True) run.add_argument("--run-id", required=True) run.add_argument("--commit", required=True) - run.add_argument("--mode", choices=("deterministic", "live"), required=True) + run.add_argument("--mode", choices=("deterministic", "live", "homebrew"), required=True) run.add_argument("--timeout-seconds", type=int, default=2100) run.add_argument("--live-key-env", choices=("OPENAI_API_KEY",), default="OPENAI_API_KEY") recover = commands.add_parser("recover") diff --git a/scripts/daytona-behaviors/homebrew.py b/scripts/daytona-behaviors/homebrew.py new file mode 100644 index 000000000..5d2f84476 --- /dev/null +++ b/scripts/daytona-behaviors/homebrew.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +"""Real brew lifecycle of retained candidate bytes, not published tap qualification. + +Requires preprovisioned non-root Homebrew. Never installs Homebrew, uses provider +credentials, or substitutes a fake brew executable. For native matrix runners: + +python3 scripts/daytona-behaviors/homebrew.py --artifacts ARCHIVE_DIRECTORY \\ + --output EVIDENCE_DIRECTORY --commit SOURCE_SHA --lifecycle-only + +The lifecycle-only mode is portable to macOS/Linux x64/arm64 and imports only +stdlib plus the sibling installed.py harness (no Daytona SDK). +""" +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import pwd +import re +import shutil +import stat +import subprocess +import sys +import tempfile + +from installed import Runner + +HERE = Path(__file__).resolve().parent +MARKER = {"schemaVersion": 1, "package": "@diffexai/diffex", "formula": "diffexai/tap/diffex"} +TOOLS = ("sh", "bash", "dirname", "readlink", "uname", "cat", "env", "tmux", "python3", "rg", "fd", + "git", "mkdir", "sed", "tr", "head", "tail", "stty", "sleep", "kill", "which", "tput", "clear", + "date", "realpath", "cut", "sort", "wc", "find", "xargs", "ls", "cp", "rm") + + +def safe_directory(value): + """Confine CLI directories before converting them to filesystem Paths.""" + value = os.fspath(value) + if ".." in value.split(os.sep): + raise ValueError("Directory traversal is not permitted") + roots = {os.path.abspath(root) for root in + (os.getcwd(), pwd.getpwuid(os.getuid()).pw_dir, tempfile.gettempdir(), "/tmp")} + if os.uname().sysname == "Darwin": + native = os.confstr(65537) # _CS_DARWIN_USER_TEMP_DIR, independent of TMPDIR. + if not native: + raise ValueError("Missing OS user temporary directory") + metadata = os.stat(os.path.realpath(native)) + if (not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != os.getuid() or + stat.S_IMODE(metadata.st_mode) & 0o077): + raise ValueError("OS user temporary directory is not owner-private") + roots.add(os.path.abspath(native)) + roots.discard(os.path.sep) + roots.update(os.path.realpath(root) for root in tuple(roots)) + # Include the separator when allowing a root itself, not its prefix siblings. + absolute = os.path.join(os.path.abspath(value), "") + if not absolute.startswith(tuple(root + os.sep for root in roots)): + raise ValueError("Directory outside local harness roots") + scope = max((root for root in roots if absolute.startswith(root + os.sep)), key=len) + canonical_scope = os.path.realpath(scope) + canonical = os.path.join(os.path.realpath(absolute), "") + if not canonical.startswith(canonical_scope + os.sep): + raise ValueError("Directory outside local harness root") + expected = os.path.join(os.path.abspath(os.path.join(canonical_scope, os.path.relpath(absolute, scope))), "") + if canonical != expected: + raise ValueError("Directory traverses a symlink") + return Path(canonical) + + +def retained_file(directory, name, suffix): + """Validate a retained basename and reject symlinks before accessing its bytes.""" + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*" + re.escape(suffix), name): + raise ValueError("Invalid retained filename") + directory = safe_directory(directory) + absolute = os.path.abspath(os.path.join(directory, name)) + if not absolute.startswith(str(directory) + os.sep): + raise ValueError("Retained file must be directly inside the artifact directory") + canonical = os.path.realpath(absolute) + if not canonical.startswith(str(directory) + os.sep): + raise ValueError("Retained file must be directly inside the artifact directory") + if canonical != absolute: + raise ValueError("Retained file must not traverse symlinks") + path = Path(canonical) + if not path.is_file(): + raise ValueError("Retained artifact must be a regular file") + return path + + +def candidate_formula(archive, record, revision=0): + # Explicit trusted prepublication file fixture. No production URL override API. + # Brew strips the sole diffex/ archive root before the canonical install stanza. + archive = retained_file(safe_directory(archive.parent), archive.name, ".tar.gz") + return f'''class Diffex < Formula + desc "Diffex retained candidate lifecycle fixture (not the published tap)" + homepage "https://github.com/diffexai/diffex" + url {json.dumps(archive.as_uri())} + version {json.dumps(record["version"])} + sha256 {json.dumps(record["sha256"])} + revision {revision} + def install + libexec.install buildpath => "diffex" + (libexec/".diffex-homebrew.json").write {json.dumps(json.dumps(MARKER))} + bin.install_symlink libexec/"diffex/bin/diffex" + end + test do + assert_match version.to_s, shell_output("#{{bin}}/diffex --version") + end +end +''' + + +def verify_payload_inventory(payload, record): + """Read-only comparison with the retained archive's exact nondirectory inventory.""" + payload = Path(payload).resolve(strict=True) + inventory_path = payload / "distribution-files.json" + inventory_info = inventory_path.lstat() + assert stat.S_ISREG(inventory_info.st_mode) and inventory_info.st_size <= 32 * 1024 * 1024, "Invalid payload inventory file" + assert stat.S_IMODE(inventory_info.st_mode) == 0o644, "Payload inventory mode changed" + inventory_bytes = inventory_path.read_bytes() + inventory_hash = hashlib.sha256(inventory_bytes).hexdigest() + assert inventory_hash == record["inventorySha256"], "Retained payload inventory SHA256 mismatch" + inventory = json.loads(inventory_bytes) + assert inventory["schemaVersion"] == 1 and inventory["algorithm"] == "sha256", "Invalid payload inventory schema" + assert isinstance(inventory["files"], list) and 0 < len(inventory["files"]) <= 100000, "Invalid payload inventory entries" + expected = {} + folded = set() + for item in inventory["files"]: + name = item["path"] + path = PurePosixPath(name) + assert (name and not path.is_absolute() and str(path) == name and ".." not in path.parts + and "\\" not in name and ":" not in name and not any(ord(char) < 32 or ord(char) == 127 for char in name)), "Unsafe payload inventory path" + assert name != "distribution-files.json" and name.casefold() not in folded, "Duplicate or self-referencing payload inventory path" + folded.add(name.casefold()) + assert item["type"] in {"file", "link"}, "Invalid payload inventory type" + expected[name] = item + actual = {} + for directory, dirs, files in os.walk(payload, followlinks=False): + for name in dirs + files: + path = Path(directory, name) + info = path.lstat() + if stat.S_ISDIR(info.st_mode): + continue + local = path.relative_to(payload).as_posix() + assert stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode), "Special file in installed payload" + if local != "distribution-files.json": + actual[local] = (path, info) + dirs[:] = [name for name in dirs if not Path(directory, name).is_symlink()] + assert actual.keys() == expected.keys(), "Installed payload path set differs from retained inventory" + for name, item in expected.items(): + path, info = actual[name] + assert type(item["mode"]) is int and stat.S_IMODE(info.st_mode) == item["mode"], "Installed payload mode differs from retained inventory" + if item["type"] == "link": + assert stat.S_ISLNK(info.st_mode) and item["mode"] in {0o755, 0o777}, "Installed payload link type changed" + assert os.readlink(path) == item["target"], "Installed payload link target changed" + assert not Path(item["target"]).is_absolute() and path.resolve(strict=True).is_relative_to(payload), "Installed payload link escapes or is dangling" + else: + assert stat.S_ISREG(info.st_mode) and item["mode"] in {0o644, 0o755}, "Installed payload file type changed" + assert type(item["size"]) is int and info.st_size == item["size"], "Installed payload size changed" + with path.open("rb") as stream: + assert hashlib.file_digest(stream, "sha256").hexdigest() == item["sha256"], "Installed payload file SHA256 changed" + metadata = json.loads((payload / "distribution.json").read_text()) + assert metadata["schemaVersion"] == 1 and metadata["product"] == "@diffexai/diffex", "Installed payload product identity changed" + for field in ("version", "sourceCommit", "platform"): + assert metadata[field] == record[field], "Installed payload source/version/platform identity changed" + for field in ("nodeVersion", "npmVersion"): + assert metadata["runtime"][field] == record[field], "Installed payload runtime identity changed" + cli = payload / "app/node_modules/@diffexai/diffex" + package = json.loads((cli / "package.json").read_text()) + assert package["name"] == "@diffexai/diffex" and package["version"] == record["version"], "Installed CLI package identity changed" + assert json.loads((cli / "distribution-files.json").read_text())["sourceCommit"] == record["sourceCommit"], "Installed CLI source identity changed" + return {"inventorySha256": inventory_hash, "filesVerified": len(expected)} + + +def link_fixture_modules(payload, fixture_root): + payload = payload.resolve() + cli = payload / "app/node_modules/@diffexai/diffex" + bundled = cli / "dist/node_modules/@diffexai" + scope = fixture_root / "node_modules/@diffexai" + scope.mkdir(parents=True) + for name, target in [("diffex", cli), *((path.name, path) for path in sorted(bundled.iterdir()))]: + assert re.fullmatch(r"[a-z0-9][a-z0-9._-]*", name) + target = target.resolve() + assert target.is_relative_to(payload), "Fixture module escapes retained payload" + assert json.loads((target / "package.json").read_text())["name"] == f"@diffexai/{name}" + (scope / name).symlink_to(target, target_is_directory=True) + assert (scope / "diffex-ai/package.json").is_file(), "Missing bundled AI fixture SDK" + assert (scope / "diffex-tui/package.json").is_file(), "Missing bundled TUI fixture SDK" + + +def fingerprint(directory): + entries = [] + for path in sorted(directory.rglob("*")): + name = path.relative_to(directory).as_posix() + if path.is_symlink(): + entries.append((name, "link", os.readlink(path))) + elif path.is_dir(): + entries.append((name, "directory", path.stat().st_mode)) + else: + entries.append((name, "file", path.stat().st_mode, hashlib.sha256(path.read_bytes()).hexdigest())) + return entries + + +def choose_record(artifacts, commit): + artifacts = safe_directory(artifacts) + if not artifacts.is_dir(): + raise ValueError("Artifact directory does not exist") + native = sys.platform + "-" + {"x86_64": "x64", "aarch64": "arm64", "arm64": "arm64"}[platform.machine()] + candidates = [] + for entry in artifacts.glob("*.json"): + path = retained_file(artifacts, entry.name, ".json") + value = json.loads(path.read_text()) + if value.get("platform") == native and value.get("filename", "").endswith(".tar.gz"): + candidates.append((path, value)) + if len(candidates) != 1: + raise AssertionError("Expected one retained native Homebrew archive record") + path, record = candidates[0] + assert record["sourceCommit"] == commit + assert re.fullmatch(r"\d+\.\d+\.\d+", record["version"]) + assert re.fullmatch(r"[a-f0-9]{64}", record["sha256"]) + archive = retained_file(artifacts, record["filename"], ".tar.gz") + content = archive.read_bytes() + assert len(content) == record["size"] and hashlib.sha256(content).hexdigest() == record["sha256"] + return archive, path, record + + +class HomebrewRunner(Runner): + def __init__(self, args): + # Existing deterministic provider/tools/TUI/cancel/resume implementation. + commit = args.commit + if not isinstance(commit, str) or not re.fullmatch(r"[a-f0-9]{40}", commit): + raise ValueError("Commit must be a full SHA") + output = safe_directory(args.output) + artifacts = getattr(args, "artifacts", None) + if artifacts is not None: + artifacts = safe_directory(artifacts) + # Do not retain the raw argparse namespace: the base harness has broader + # modes and executable/model controls which this lifecycle never accepts. + configuration = argparse.Namespace(output=output, artifacts=artifacts, commit=commit, + mode="deterministic", executable=None, + model="openai/gpt-4.1-mini", update_baselines=False, + lifecycle_only=True if getattr(args, "lifecycle_only", False) else False) + super().__init__(configuration) + self.payload = None + self.tools = None + self.fixture_root = None + + def save(self): + self.args.mode = "homebrew" + try: + super().save() + finally: + self.args.mode = "deterministic" + + def fixture_directory(self, executable): + # Resolve installed SDK from the retained app, never the bundle/repository. + return self.fixture_root + + def environment(self, root): + root.mkdir(parents=True, exist_ok=True) + env = super().environment(root) + env["PATH"] = str(self.tools) + env["DIFFEX_OFFLINE"] = "1" + env["DIFFEX_SKIP_VERSION_CHECK"] = "1" + return env + + def brew_command(self, args): + result = subprocess.run([self.brew, *args], env=self.brew_env, cwd="/tmp", capture_output=True, + text=True, timeout=300) + # Do not publish brew stdout/stderr, local paths, ambient config or SDK secrets. + assert result.returncode == 0, "Real brew command failed: " + args[0] + return result.stdout.strip() + + def run(self): + with tempfile.TemporaryDirectory(prefix="diffex-homebrew-", dir="/tmp") as directory: + work = Path(directory) + archive, record_path, record = choose_record(self.args.artifacts, self.args.commit) + self.hashes = {record["filename"]: record["sha256"]} + self.brew = shutil.which("brew") + assert self.brew and os.getuid() != 0, "Preprovisioned non-root Homebrew required" + brew_home = work / "brew-home" + brew_home.mkdir() + self.brew_env = {"PATH": os.environ["PATH"], "HOME": str(brew_home), + "USER": os.environ.get("USER", "linuxbrew"), "LANG": "C.UTF-8", + "HOMEBREW_NO_AUTO_UPDATE": "1", "HOMEBREW_NO_ANALYTICS": "1", + "HOMEBREW_NO_INSTALL_CLEANUP": "1", "HOMEBREW_NO_INSTALL_FROM_API": "1", + "HOMEBREW_CACHE": str(work / "brew-cache"), + "HOMEBREW_LOGS": str(work / "brew-logs")} + self.tools = work / "tools" + self.tools.mkdir() + for tool in TOOLS: + source = shutil.which(tool) + if source: + (self.tools / tool).symlink_to(Path(source).resolve()) + assert all(not (self.tools / name).exists() for name in ("node", "npm", "npx", "bun")) + # Brew's own dependency/runtime PATH is separate from every product invocation. + tap = "diffexai/tap" + assert tap not in self.brew_command(["tap"]).splitlines(), "Snapshot must not contain the candidate tap" + assert not any(line.split()[0] == "diffex" for line in self.brew_command(["list", "--formula", "--versions"]).splitlines()), "Snapshot must not contain diffex" + source = work / "tap" + (source / "Formula").mkdir(parents=True) + formula = source / "Formula/diffex.rb" + formula.write_text(candidate_formula(archive, record)) + for command in (["git", "init", str(source)], ["git", "-C", str(source), "add", "Formula/diffex.rb"], + ["git", "-C", str(source), "-c", "user.name=Acceptance fixture", "-c", "user.email=fixture@invalid", "commit", "-m", "Local candidate fixture"]): + subprocess.run(command, check=True, capture_output=True, timeout=15, env=self.brew_env) + tapped = installed = False + try: + def install(evidence): + nonlocal tapped, installed + self.brew_command(["tap", tap, str(source)]) + tapped = True + self.brew_command(["install", "--formula", f"{tap}/diffex"]) + installed = True + prefix = Path(self.brew_command(["--prefix", f"{tap}/diffex"])) + self.payload = (prefix / "libexec/diffex").resolve() + verified_inventory = verify_payload_inventory(self.payload, record) + assert json.loads((self.payload.parent / ".diffex-homebrew.json").read_text()) == MARKER + self.executable = str(prefix / "bin/diffex") + if not getattr(self.args, "lifecycle_only", False): + self.fixture_root = work / "external-fixtures" + link_fixture_modules(self.payload, self.fixture_root) + product_env = self.environment(work / "installation") + project = work / "installation/project" + for name, command, expected in (("help", ["--help"], "Usage"), ("version", ["--version"], record["version"]), + ("models", ["--list-models"], None)): + command_evidence = evidence / name + command_evidence.mkdir() + output = self.process(self.executable, command, product_env, project, command_evidence) + assert output.strip() and (expected is None or expected in output), "Installed command output mismatch" + protected = [self.payload, Path(product_env["HOME"]), Path(product_env["DIFFEX_CODING_AGENT_DIR"]), work / "installation/project"] + before_update = [fingerprint(path) for path in protected] + for name, command in (("update", ["update"]), ("forced-update", ["update", "--self", "--force"])): + command_evidence = evidence / name + command_evidence.mkdir() + output = self.process(self.executable, command, product_env, project, command_evidence) + assert "brew upgrade diffexai/tap/diffex" in output + assert [fingerprint(path) for path in protected] == before_update, "Owned update changed payload/user/project state" + verified_inventory = verify_payload_inventory(self.payload, record) + (evidence / "lifecycle.json").write_text(json.dumps({"brew": "real", "source": "trusted local file candidate", "revision": 0, + "commands": ["help", "version", "models"], "ownedUpdateNoMutation": True, "payloadInventory": verified_inventory})) + if not self.scenario("homebrew-formula-install", install, "configuration"): + return False + + def archive_acceptance(evidence): + # Harness Node only; tested CLI subprocesses use restricted tools PATH. + result = subprocess.run(["node", str(HERE.parent / "homebrew-acceptance.mjs"), str(archive), str(record_path), str(evidence)], + capture_output=True, timeout=240, env=self.brew_env) + assert result.returncode == 0, "Retained archive acceptance failed" + summary = json.loads((evidence / "homebrew-acceptance.json").read_text()) + assert summary["status"] == "passed" and summary["cleanup"] is True + if not getattr(self.args, "lifecycle_only", False): + self.scenario("homebrew-retained-archive", archive_acceptance, "configuration") + self.deterministic(self.executable, "homebrew", include_artifact_terminal=False) + + # User data is outside the keg; retain real app-created session data plus canaries. + user = work / "user-data" + env = self.environment(user) + # Match brew's HOME and the default user-data location, so a formula + # accidentally deleting ~/.diffex would fail these assertions. + agent = brew_home / ".diffex/agent" + env["HOME"] = str(brew_home) + env["DIFFEX_CODING_AGENT_DIR"] = str(agent) + for name in ("auth.json", "settings.json", "sessions/retention.jsonl", "extensions/retention.ts", "npm/node_modules/diffex-homebrew-fixture/package.json"): + target = agent / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps({"fixture": "retain-me"}) + "\n") + session_evidence = self.output / "homebrew-terminal-tools-cancel-resume/sessions" + if session_evidence.exists(): + shutil.copytree(session_evidence, agent / "sessions/real-conversation") + before = {path.relative_to(agent).as_posix(): path.read_bytes() for path in agent.rglob("*") if path.is_file()} + + def retain(): + assert all((agent / name).read_bytes() == content for name, content in before.items()), "User data was changed" + + def upgrade(evidence): + tap_root = Path(self.brew_command(["--repository", tap])) + (tap_root / "Formula/diffex.rb").write_text(candidate_formula(archive, record, revision=1)) + old_payload = self.payload + self.brew_command(["upgrade", "--formula", f"{tap}/diffex"]) + prefix = Path(self.brew_command(["--prefix", f"{tap}/diffex"])) + self.payload = (prefix / "libexec/diffex").resolve() + assert self.payload != old_payload, "Brew did not upgrade to a distinct revision keg" + verified_inventory = verify_payload_inventory(self.payload, record) + self.process(str(prefix / "bin/diffex"), ["--version"], env, work, evidence) + verified_inventory = verify_payload_inventory(self.payload, record) + retain() + (evidence / "lifecycle.json").write_text(json.dumps({"brew": "real", "revision": 1, + "upgrade": "same retained bytes; formula revision fixture, not a second released version", "userDataRetained": True, + "payloadInventory": verified_inventory})) + self.scenario("homebrew-formula-upgrade-retention", upgrade) + + def uninstall(evidence): + nonlocal installed + self.brew_command(["uninstall", "--force", f"{tap}/diffex"]) + installed = False + assert not any(line.split()[0] == "diffex" for line in self.brew_command(["list", "--formula", "--versions"]).splitlines()) + retain() + (evidence / "lifecycle.json").write_text(json.dumps({"brew": "real", "uninstalled": True, "userDataRetained": True})) + self.scenario("homebrew-formula-uninstall-retention", uninstall) + finally: + if installed: + self.brew_command(["uninstall", "--force", f"{tap}/diffex"]) + if tapped: + self.brew_command(["untap", tap]) + # Never publish home, npm cache/config, auth files or raw command output. + self.save() + return bool(self.results) and all(item["outcome"] == "passed" for item in self.results) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifacts", type=safe_directory, required=True) + parser.add_argument("--output", type=safe_directory, required=True) + parser.add_argument("--commit", required=True) + parser.add_argument("--mode", choices=["homebrew"], default="homebrew") + parser.add_argument("--lifecycle-only", action="store_true", + help="Native real-brew install/upgrade/uninstall; no Daytona SDK, tmux or TUI scenarios required") + args = parser.parse_args() + if not re.fullmatch(r"[a-f0-9]{40}", args.commit): + parser.error("--commit must be a full SHA") + runner = HomebrewRunner(args) + try: + passed = runner.run() + except Exception: + runner.scenario("homebrew-prerequisites-or-cleanup", lambda _: (_ for _ in ()).throw(AssertionError("Homebrew prerequisite or cleanup failure")), "configuration") + passed = False + raise SystemExit(0 if passed else 1) + + +if __name__ == "__main__": + main() diff --git a/scripts/daytona-behaviors/installed.py b/scripts/daytona-behaviors/installed.py index 0389ffde0..ae197423b 100644 --- a/scripts/daytona-behaviors/installed.py +++ b/scripts/daytona-behaviors/installed.py @@ -246,12 +246,15 @@ def process(self, executable, args, env, project, evidence): assert result.returncode == 0, f"Exit code {result.returncode}; see stdout/stderr" return result.stdout + def fixture_directory(self, executable): + return Path(executable).parent.parent.parent + def behavior(self, executable, evidence, action): with tempfile.TemporaryDirectory(prefix="db-", dir="/tmp") as temporary: root = Path(temporary) env = self.environment(root) # Keep the fixture beside node_modules so its import resolves the installed SDK, never the repository. - fixture = Path(executable).parent.parent.parent / ("behavior-" + uuid.uuid4().hex + ".mjs") + fixture = self.fixture_directory(executable) / ("behavior-" + uuid.uuid4().hex + ".mjs") shutil.copyfile(HERE / ("live-guard.mjs" if self.args.mode == "live" else "fixture.mjs"), fixture) try: action(env, root, fixture) @@ -276,7 +279,7 @@ def behavior(self, executable, evidence, action): # Never copy home, auth files, settings, or process environment. # Redaction happens BEFORE files become incrementally downloadable. - def deterministic(self, executable, runtime): + def deterministic(self, executable, runtime, include_artifact_terminal=True): def run_case(name, fn): return self.scenario(runtime + "-" + name, lambda evidence: self.behavior(executable, evidence, lambda env, root, fixture: fn(env, root, fixture, evidence))) @@ -355,7 +358,8 @@ def legacy(evidence): module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) module.verify(evidence, {runtime: executable}, HERE.parent / "fixtures/artifact-handoff.mjs") - self.scenario(runtime + "-artifact-terminal", legacy) + if include_artifact_terminal: + self.scenario(runtime + "-artifact-terminal", legacy) def live(self, executable): def operation(evidence): diff --git a/scripts/daytona-behaviors/test_homebrew.py b/scripts/daytona-behaviors/test_homebrew.py new file mode 100644 index 000000000..4801c0ffc --- /dev/null +++ b/scripts/daytona-behaviors/test_homebrew.py @@ -0,0 +1,456 @@ +"""Credential-free Homebrew fixture/report contracts, not real brew qualification.""" +import argparse +import hashlib +import io +import json +import os +import stat +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from homebrew import HomebrewRunner, MARKER, candidate_formula, choose_record, fingerprint, link_fixture_modules, main, safe_directory, verify_payload_inventory +from daytona_runner import REMOTE_BUNDLE, Runner as DaytonaRunner, State +from test_daytona_runner import FakeClient +import time + + +def seal_payload_fixture(payload, record): + files = [] + for directory, dirs, names in os.walk(payload, followlinks=False): + for name in dirs + names: + path = Path(directory, name) + local = path.relative_to(payload).as_posix() + info = path.lstat() + if stat.S_ISDIR(info.st_mode) or local == "distribution-files.json": + continue + item = {"path": local, "mode": stat.S_IMODE(info.st_mode)} + if path.is_symlink(): + item.update(type="link", target=os.readlink(path)) + else: + item.update(type="file", size=info.st_size, sha256=hashlib.sha256(path.read_bytes()).hexdigest()) + files.append(item) + content = (json.dumps({"schemaVersion": 1, "algorithm": "sha256", "files": sorted(files, key=lambda item: item["path"])}, sort_keys=True) + "\n").encode() + inventory = payload / "distribution-files.json" + inventory.write_bytes(content) + inventory.chmod(0o644) + record["inventorySha256"] = hashlib.sha256(content).hexdigest() + + +def make_payload_fixture(payload): + record = {"version": "1.2.3", "sourceCommit": "a" * 40, "platform": "linux-x64", + "nodeVersion": "22.23.2", "npmVersion": "11.16.0"} + cli = payload / "app/node_modules/@diffexai/diffex" + cli.mkdir(parents=True, exist_ok=True) + (payload / "bin").mkdir(exist_ok=True) + for path, value in ((payload / "distribution.json", {"schemaVersion": 1, "product": "@diffexai/diffex", + "version": record["version"], "sourceCommit": record["sourceCommit"], "platform": record["platform"], + "runtime": {key: record[key] for key in ("nodeVersion", "npmVersion")}}), + (cli / "package.json", {"name": "@diffexai/diffex", "version": record["version"]}), + (cli / "distribution-files.json", {"sourceCommit": record["sourceCommit"]})): + path.write_text(json.dumps(value)) + path.chmod(0o644) + (payload / "data.txt").write_text("preserved bytes") + (payload / "data.txt").chmod(0o644) + (payload / "bin/diffex").write_text("#!/bin/sh\nexit 0\n") + (payload / "bin/diffex").chmod(0o755) + link = payload / "bin/alias" + link.symlink_to("diffex") + seal_payload_fixture(payload, record) + return record + + +class HomebrewTests(unittest.TestCase): + def test_formula_uses_explicit_local_archive_and_canonical_marker_layout(self): + with tempfile.TemporaryDirectory() as temporary: + archive = Path(temporary) / "candidate.tar.gz" + archive.write_bytes(b"retained") + record = {"version": "1.2.3", "sha256": "a" * 64} + formula = candidate_formula(archive, record, 1) + self.assertIn(archive.resolve().as_uri(), formula) + self.assertIn('libexec.install buildpath => "diffex"', formula) + self.assertIn('bin.install_symlink libexec/"diffex/bin/diffex"', formula) + self.assertIn("revision 1", formula) + self.assertIn("diffexai/tap/diffex", formula) + self.assertEqual(MARKER["schemaVersion"], 1) + self.assertNotIn("https://github.com/diffexai/diffex/releases/download", formula) + + def test_cli_directories_preserve_native_temporary_paths_and_reject_external_roots(self): + for parent in (None, "/tmp"): + with self.subTest(parent=parent), tempfile.TemporaryDirectory(dir=parent) as temporary: + root = Path(temporary).resolve() + self.assertEqual(safe_directory(root / "retained"), root / "retained") + runner = HomebrewRunner(argparse.Namespace(output=root / "evidence", artifacts=root, + commit="a" * 40)) + self.assertEqual(runner.output, root / "evidence") + self.assertEqual(runner.args.artifacts, root) + with self.assertRaises(ValueError): + safe_directory("/etc") + with self.assertRaises(ValueError): + HomebrewRunner(argparse.Namespace(output=Path("/etc"), commit="a" * 40)) + with tempfile.TemporaryDirectory() as temporary: + link = Path(temporary) / "escape" + link.symlink_to("/etc", target_is_directory=True) + with self.assertRaises(ValueError): + safe_directory(link / "evidence") + + def test_runner_copies_only_validated_lifecycle_configuration(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + args = argparse.Namespace(output=root / "evidence", artifacts=root, commit="a" * 40, + mode="live", executable=Path("/untrusted/executable"), + model="untrusted/model", update_baselines=True, lifecycle_only=True, + unexpected="must not propagate") + runner = HomebrewRunner(args) + self.assertIsNot(runner.args, args) + self.assertEqual(vars(runner.args), {"output": (root / "evidence").resolve(), + "artifacts": root.resolve(), "commit": "a" * 40, "mode": "deterministic", + "executable": None, "model": "openai/gpt-4.1-mini", "update_baselines": False, + "lifecycle_only": True}) + self.assertEqual(args.mode, "live") + args.output = Path("/etc") + args.commit = "unvalidated" + self.assertEqual(runner.args.commit, "a" * 40) + self.assertEqual(runner.args.output, (root / "evidence").resolve()) + + def test_cli_and_runner_require_full_commit_before_creating_evidence(self): + for commit in ("short", "A" * 40, "a" * 39, "a" * 40 + "\n", "a" * 40 + ";other"): + with self.subTest(commit=commit), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = root / "evidence" + with self.assertRaisesRegex(ValueError, "full SHA"): + HomebrewRunner(argparse.Namespace(output=output, artifacts=root, commit=commit)) + with patch("homebrew.sys.argv", ["homebrew.py", "--artifacts", str(root), "--output", str(output), + "--commit", commit]), patch("homebrew.sys.stderr", new=io.StringIO()), \ + patch("homebrew.HomebrewRunner") as runner: + with self.assertRaises(SystemExit) as failure: + main() + self.assertEqual(failure.exception.code, 2) + runner.assert_not_called() + self.assertFalse(output.exists()) + + def test_valid_cli_passes_only_checked_fields_to_the_lifecycle_runner(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + with patch("homebrew.sys.argv", ["homebrew.py", "--artifacts", str(root), + "--output", str(root / "evidence"), "--commit", "a" * 40, + "--lifecycle-only"]), \ + patch.object(HomebrewRunner, "run", autospec=True, return_value=True) as run: + with self.assertRaises(SystemExit) as result: + main() + self.assertEqual(result.exception.code, 0) + configuration = run.call_args.args[0].args + self.assertEqual(configuration.artifacts, root.resolve()) + self.assertEqual(configuration.output, (root / "evidence").resolve()) + self.assertEqual(configuration.commit, "a" * 40) + self.assertTrue(configuration.lifecycle_only) + self.assertIsNone(configuration.executable) + self.assertEqual(configuration.mode, "deterministic") + + def test_cli_directory_rejects_traversal_and_internal_symlinks_before_access(self): + with patch("homebrew.Path", side_effect=AssertionError("must validate before Path conversion")): + with self.assertRaisesRegex(ValueError, "outside"): + safe_directory("/tmp-diffex-not-a-root/evidence") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + traversal = root / "unused/../evidence" + with patch("homebrew.os.path.realpath", side_effect=AssertionError("must not resolve traversal")): + with self.assertRaisesRegex(ValueError, "traversal"): + safe_directory(traversal) + target = root / "target" + target.mkdir() + link = root / "redirect" + link.symlink_to(target, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "symlink"): + safe_directory(link / "evidence") + self.assertEqual(safe_directory(Path.cwd()), Path.cwd().resolve()) + self.assertEqual(safe_directory(tempfile.gettempdir()), Path(tempfile.gettempdir()).resolve()) + + def test_native_user_temporary_directory_survives_an_isolated_tmpdir(self): + if os.uname().sysname != "Darwin": + self.skipTest("Darwin native user temporary-directory contract") + with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory(dir="/tmp") as isolated: + root = Path(temporary) + with patch("homebrew.tempfile.gettempdir", return_value=isolated), patch("homebrew.os.getcwd", return_value=isolated): + self.assertEqual(safe_directory(root / "evidence"), root.resolve() / "evidence") + + def test_formula_rejects_archive_symlinks_and_unexpected_filenames(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + archive = root / "retained/candidate.tar.gz" + archive.parent.mkdir() + archive.write_bytes(b"retained") + outside = root / "retained-sibling/candidate.tar.gz" + outside.parent.mkdir() + outside.write_bytes(b"outside") + record = {"version": "1.2.3", "sha256": "a" * 64} + for target in (archive, outside): + with self.subTest(target=target): + link = archive.parent / "linked.tar.gz" + link.symlink_to(target) + with self.assertRaises(ValueError): + candidate_formula(link, record) + link.unlink() + with self.assertRaises(ValueError): + candidate_formula(archive.with_name("not-an-archive.txt"), record) + + def test_record_selection_rejects_unsafe_archive_names_and_symlink_escapes(self): + with tempfile.TemporaryDirectory() as temporary, patch("homebrew.platform.machine", return_value="x86_64"), patch("homebrew.sys.platform", "linux"): + root = Path(temporary) + artifacts = root / "retained" + artifacts.mkdir() + outside = root / "retained-sibling/candidate.tar.gz" + outside.parent.mkdir() + outside.write_bytes(b"retained") + record = {"version": "1.2.3", "sourceCommit": "a" * 40, "platform": "linux-x64", + "filename": "candidate.tar.gz", "size": 8, "sha256": hashlib.sha256(b"retained").hexdigest()} + record_path = artifacts / "candidate.json" + for name in ("../retained-sibling/candidate.tar.gz", str(outside), "nested/candidate.tar.gz", + r"..\candidate.tar.gz", "C:candidate.tar.gz", ".hidden.tar.gz", "line\nbreak.tar.gz"): + with self.subTest(filename=name): + record_path.write_text(json.dumps({**record, "filename": name})) + with self.assertRaises(ValueError): + choose_record(artifacts, "a" * 40) + record_path.write_text(json.dumps(record)) + archive = artifacts / "candidate.tar.gz" + archive.symlink_to(outside) + with self.assertRaises(ValueError): + choose_record(artifacts, "a" * 40) + archive.unlink() + archive.write_bytes(b"retained") + external_record = outside.with_suffix(".json") + record_path.replace(external_record) + record_path.symlink_to(external_record) + with self.assertRaises(ValueError): + choose_record(artifacts, "a" * 40) + + def test_record_selection_verifies_source_hash_size_and_unique_native_record(self): + with tempfile.TemporaryDirectory() as temporary, patch("homebrew.platform.machine", return_value="x86_64"), patch("homebrew.sys.platform", "linux"): + root = Path(temporary) + archive = root / "candidate.tar.gz" + archive.write_bytes(b"retained") + record = {"version": "1.2.3", "sourceCommit": "a" * 40, "platform": "linux-x64", + "filename": archive.name, "size": 8, "sha256": hashlib.sha256(b"retained").hexdigest()} + path = root / "candidate.json" + path.write_text(json.dumps(record)) + self.assertEqual(choose_record(root, "a" * 40), (archive.resolve(), path.resolve(), record)) + duplicate = root / "duplicate.json" + duplicate.write_text(json.dumps(record)) + with self.assertRaises(AssertionError): + choose_record(root, "a" * 40) + duplicate.unlink() + with self.assertRaises(AssertionError): + choose_record(root, "b" * 40) + archive.write_bytes(b"tampered") + with self.assertRaises(AssertionError): + choose_record(root, "a" * 40) + + def test_record_selection_supports_all_four_native_targets(self): + for os_name, machine, target in (("darwin", "arm64", "darwin-arm64"), ("darwin", "x86_64", "darwin-x64"), + ("linux", "aarch64", "linux-arm64"), ("linux", "x86_64", "linux-x64")): + with self.subTest(target=target), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + archive = root / "candidate.tar.gz" + archive.write_bytes(b"fixture") + record = {"version": "1.2.3", "sourceCommit": "a" * 40, "platform": target, + "filename": archive.name, "size": 7, "sha256": hashlib.sha256(b"fixture").hexdigest()} + (root / "candidate.json").write_text(json.dumps(record)) + with patch("homebrew.platform.machine", return_value=machine), patch("homebrew.sys.platform", os_name): + self.assertEqual(choose_record(root, "a" * 40)[2]["platform"], target) + + def test_native_lifecycle_only_orchestration_contract_uses_no_daytona_or_tui(self): + # This mock checks control flow only. It does not qualify real brew behavior. + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runner = HomebrewRunner(argparse.Namespace(output=root / "evidence", commit="a" * 40, + artifacts=root, lifecycle_only=True)) + record = {"version": "1.2.3", "sha256": "b" * 64, "filename": "candidate.tar.gz"} + (root / "candidate.tar.gz").write_bytes(b"retained") + calls = [] + revision = 0 + present = False + tap = root / "tap" + (tap / "Formula").mkdir(parents=True) + prefixes = [] + for index in (0, 1): + prefix = root / f"keg-{index}" + (prefix / "libexec/diffex/app/node_modules").mkdir(parents=True) + (prefix / "libexec/.diffex-homebrew.json").write_text(json.dumps(MARKER)) + record.update(make_payload_fixture(prefix / "libexec/diffex")) + prefixes.append(prefix) + def brew(command): + nonlocal revision, present + calls.append(command) + if command[0] == "install": + present = True + elif command[0] == "upgrade": + revision = 1 + elif command[0] == "uninstall": + present = False + elif command[0] == "--prefix": + return str(prefixes[revision]) + elif command[0] == "--repository": + return str(tap) + elif command[0] == "list": + return "diffex 1.2.3" if present else "" + return "" + def product(executable, args, env, project, evidence): + self.assertNotIn("node", [path.name for path in Path(env["PATH"]).iterdir()]) + self.assertEqual(env["DIFFEX_OFFLINE"], "1") + output = "brew upgrade diffexai/tap/diffex" if args[0] == "update" else "Usage 1.2.3 models" + (evidence / "stdout.txt").write_text(output) + return output + runner.brew_command = brew + runner.process = product + with patch("homebrew.choose_record", return_value=(root / "candidate.tar.gz", root / "candidate.json", record)), \ + patch("homebrew.shutil.which", side_effect=lambda name: "/fixture/brew" if name == "brew" else None), \ + patch("homebrew.os.getuid", return_value=os.getuid() or 1000), \ + patch("homebrew.pwd.getpwuid", return_value=argparse.Namespace(pw_dir=str(root))), \ + patch("homebrew.subprocess.run"), patch.object(runner, "deterministic") as tui: + self.assertTrue(runner.run()) + tui.assert_not_called() + self.assertEqual([item["id"] for item in runner.results], ["homebrew-formula-install", "homebrew-formula-upgrade-retention", "homebrew-formula-uninstall-retention"]) + self.assertIn(["upgrade", "--formula", "diffexai/tap/diffex"], calls) + self.assertEqual(calls[-1], ["untap", "diffexai/tap"]) + for scenario in ("homebrew-formula-install", "homebrew-formula-upgrade-retention"): + evidence = json.loads((root / "evidence" / scenario / "lifecycle.json").read_text()) + self.assertEqual(evidence["payloadInventory"]["inventorySha256"], record["inventorySha256"]) + self.assertGreater(evidence["payloadInventory"]["filesVerified"], 0) + for path in (root / "evidence").rglob("*"): + self.assertNotEqual(path.name, "auth.json") + self.assertNotEqual(path.name, ".npmrc") + + def test_installed_inventory_is_read_only_and_rejects_byte_mode_type_path_link_and_identity_mutations(self): + for mutation in (None, "inventory", "bytes", "size", "mode", "missing", "extra", "type", "link", "source", "version", "platform", "runtime", "unsafe-path", "special"): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as temporary: + payload = Path(temporary) / "payload" + record = make_payload_fixture(payload) + data = payload / "data.txt" + if mutation == "inventory": + with (payload / "distribution-files.json").open("a") as stream: + stream.write(" ") + elif mutation == "bytes": + data.write_bytes(b"x" + data.read_bytes()[1:]) + elif mutation == "size": + data.write_text("truncated") + elif mutation == "mode": + data.chmod(0o744) + elif mutation == "missing": + data.unlink() + elif mutation == "extra": + (payload / "extra").write_text("unretained") + elif mutation == "type": + data.unlink() + data.symlink_to("bin/diffex") + elif mutation == "link": + (payload / "bin/alias").unlink() + (payload / "bin/alias").symlink_to("../data.txt") + elif mutation in {"source", "version", "platform", "runtime"}: + metadata = json.loads((payload / "distribution.json").read_text()) + if mutation == "runtime": + metadata["runtime"]["nodeVersion"] = "99.0.0" + else: + metadata[{"source": "sourceCommit", "version": "version", "platform": "platform"}[mutation]] = "different" + (payload / "distribution.json").write_text(json.dumps(metadata)) + seal_payload_fixture(payload, record) + elif mutation == "unsafe-path": + inventory_path = payload / "distribution-files.json" + inventory = json.loads(inventory_path.read_text()) + inventory["files"][0]["path"] = "../escape" + inventory_path.write_text(json.dumps(inventory)) + record["inventorySha256"] = hashlib.sha256(inventory_path.read_bytes()).hexdigest() + elif mutation == "special": + os.mkfifo(payload / "fifo") + before = fingerprint(payload) if mutation != "special" else None + if mutation is None: + result = verify_payload_inventory(payload, record) + self.assertEqual(result["inventorySha256"], record["inventorySha256"]) + else: + with self.assertRaises(AssertionError): + verify_payload_inventory(payload, record) + if before is not None: + self.assertEqual(fingerprint(payload), before, "Inventory verification mutated the candidate") + + def test_installed_inventory_requires_exact_declared_native_symlink_mode(self): + with tempfile.TemporaryDirectory() as temporary: + payload = Path(temporary) / "payload" + record = make_payload_fixture(payload) + link = payload / "bin/alias" + mode = stat.S_IMODE(link.lstat().st_mode) + self.assertIn(mode, {0o755, 0o777}) + verify_payload_inventory(payload, record) + inventory_path = payload / "distribution-files.json" + inventory = json.loads(inventory_path.read_text()) + item = next(item for item in inventory["files"] if item["path"] == "bin/alias") + self.assertEqual(item["mode"], mode) + item["mode"] = 0o755 if mode == 0o777 else 0o777 + inventory_path.write_text(json.dumps(inventory)) + record["inventorySha256"] = hashlib.sha256(inventory_path.read_bytes()).hexdigest() + before = fingerprint(payload) + with self.assertRaisesRegex(AssertionError, "mode differs"): + verify_payload_inventory(payload, record) + self.assertEqual(fingerprint(payload), before) + + def test_external_provider_modules_link_to_cli_bundled_layout_without_top_level_ai_or_tui(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + payload = root / "payload" + cli = payload / "app/node_modules/@diffexai/diffex" + bundled = cli / "dist/node_modules/@diffexai" + for name, path in (("diffex", cli), ("diffex-ai", bundled / "diffex-ai"), ("diffex-tui", bundled / "diffex-tui")): + path.mkdir(parents=True, exist_ok=True) + (path / "package.json").write_text(json.dumps({"name": f"@diffexai/{name}"})) + self.assertEqual([path.name for path in (payload / "app/node_modules/@diffexai").iterdir()], ["diffex"]) + before = fingerprint(payload) + external = root / "external" + link_fixture_modules(payload, external) + scope = external / "node_modules/@diffexai" + self.assertEqual((scope / "diffex").resolve(), cli.resolve()) + self.assertEqual((scope / "diffex-ai").resolve(), (bundled / "diffex-ai").resolve()) + self.assertEqual((scope / "diffex-tui").resolve(), (bundled / "diffex-tui").resolve()) + self.assertEqual(fingerprint(payload), before) + + def test_shared_behavior_harness_resolves_installed_sdk_and_restricts_product_path(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + runner = HomebrewRunner(argparse.Namespace(output=root / "evidence", commit="a" * 40)) + runner.payload = root / "cellar/libexec/diffex" + runner.tools = root / "tools" + runner.fixture_root = root / "external-fixtures" + env = runner.environment(root / "work") + self.assertEqual(env["PATH"], str(runner.tools)) + self.assertNotIn("OPENAI_API_KEY", env) + self.assertNotIn("DAYTONA_API_KEY", env) + self.assertEqual(runner.fixture_directory("/brew/bin/diffex"), runner.fixture_root) + self.assertEqual(env["DIFFEX_OFFLINE"], "1") + self.assertEqual(env["DIFFEX_SKIP_VERSION_CHECK"], "1") + runner.scenario("fixture", lambda evidence: (evidence / "safe.json").write_text("{}")) + self.assertEqual(json.loads((root / "evidence/results.json").read_text())["mode"], "homebrew") + self.assertEqual(runner.args.mode, "deterministic") + + def test_daytona_homebrew_launch_never_forwards_provider_keys_and_recovers_without_relaunch(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = root / "output" + output.mkdir() + state = State("workflow-homebrew", "unique-homebrew", {"diffex-run": "fixture"}, + "a" * 40, "homebrew", "brew-toolchain", time.time(), time.time() + 60) + state.save(output) + client = FakeClient(output) + runner = DaytonaRunner(client, output, state, poll_seconds=0) + sandbox = runner.create() + with patch.dict(os.environ, {"OPENAI_API_KEY": "canary-no-forward"}): + runner.launch(sandbox, "canary-no-forward") + launch = client.fs.files["/tmp/diffex-launch.py"].decode() + self.assertIn(f"{REMOTE_BUNDLE}/scripts/daytona-behaviors/homebrew.py", launch) + self.assertNotIn("canary-no-forward", launch) + self.assertTrue(all(not env for _, env in client.process.calls)) + self.assertEqual(runner.recover(0), 0) + self.assertEqual(client.creates, 1) + self.assertEqual(sum(command.startswith("nohup ") for command, _ in client.process.calls), 1) + self.assertEqual(state.cleanup, "confirmed") + + +if __name__ == "__main__": + unittest.main() diff --git a/diffex-test.bat b/scripts/diffex-test.bat similarity index 100% rename from diffex-test.bat rename to scripts/diffex-test.bat diff --git a/diffex-test.ps1 b/scripts/diffex-test.ps1 similarity index 90% rename from diffex-test.ps1 rename to scripts/diffex-test.ps1 index fd4223799..b4c2f5f04 100644 --- a/diffex-test.ps1 +++ b/scripts/diffex-test.ps1 @@ -1,6 +1,7 @@ $ErrorActionPreference = "Stop" $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent $scriptDir $noEnv = $false $forwardArgs = New-Object System.Collections.Generic.List[string] @@ -58,12 +59,12 @@ if ($noEnv) { Write-Host "Running without API keys..." } -$tsxBin = Join-Path $scriptDir "node_modules/.bin/tsx.cmd" +$tsxBin = Join-Path $repoRoot "node_modules/.bin/tsx.cmd" if (-not (Test-Path -LiteralPath $tsxBin)) { throw "tsx not found at $tsxBin. Run npm install from the repository root first." } -$cliPath = Join-Path $scriptDir "packages/coding-agent/src/cli.ts" +$cliPath = Join-Path $repoRoot "packages/coding-agent/src/cli.ts" & $tsxBin $cliPath @forwardArgs $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { diff --git a/diffex-test.sh b/scripts/diffex-test.sh similarity index 88% rename from diffex-test.sh rename to scripts/diffex-test.sh index 3bff741aa..b08768775 100755 --- a/diffex-test.sh +++ b/scripts/diffex-test.sh @@ -11,7 +11,7 @@ while [[ -L "$SCRIPT_PATH" ]]; do fi done -SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" +REPO_ROOT="$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)" # Check for --no-env flag NO_ENV=false @@ -64,4 +64,4 @@ if [[ "$NO_ENV" == "true" ]]; then echo "Running without API keys..." fi -exec "$SCRIPT_DIR/node_modules/.bin/tsx" --tsconfig "$SCRIPT_DIR/tsconfig.json" "$SCRIPT_DIR/packages/coding-agent/src/cli.ts" ${ARGS[@]+"${ARGS[@]}"} +exec "$REPO_ROOT/node_modules/.bin/tsx" --tsconfig "$REPO_ROOT/tsconfig.json" "$REPO_ROOT/packages/coding-agent/src/cli.ts" ${ARGS[@]+"${ARGS[@]}"} diff --git a/scripts/distribution-payload.test.mjs b/scripts/distribution-payload.test.mjs index 762b13d42..fd0019f2a 100644 --- a/scripts/distribution-payload.test.mjs +++ b/scripts/distribution-payload.test.mjs @@ -26,7 +26,7 @@ function fixture(extra, omit) { "distribution-components.json": JSON.stringify([{ name: "Diffex", license: "MIT" }]), "dist/index.js": "export const value = 1;\n", "dist/index.d.ts": "export declare const value: number;\n", - "dist/AGENTS.md": readFileSync("AGENTS_release.md"), + "dist/AGENTS.md": readFileSync("scripts/assets/default-agent.md"), "release/distribution-manifest.json": JSON.stringify( createDistributionManifest({ packages: getPublicWorkspacePackages(), @@ -139,7 +139,6 @@ for (const [name, content, reason] of [ ["examples/example.ts", "export const value = 1", /prohibited source or private path/], ["AGENTS.md", "Private repository instructions", /prohibited repository instructions/], ["dist/AGENTS.md", "Private repository instructions", /prohibited repository instructions/], - ["AGENTS_release.md", readFileSync("AGENTS_release.md"), /unapproved distribution documentation/], ["docs/agents.MD", "Private repository instructions", /prohibited repository instructions/], [ "dist/node_modules/@diffexai/diffex-ai/AGENTS.md", @@ -154,6 +153,14 @@ for (const [name, content, reason] of [ }); } +test("rejects prohibited content in scripts/assets/default-agent.md", () => { + const result = inspect({ + "scripts/assets/default-agent.md": readFileSync("scripts/assets/default-agent.md"), + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /unapproved distribution documentation/); +}); + test("inspects a nested archive even when renamed to a binary resource", () => { const nested = fixture({ "dist/private.ts": "export const hidden = 1" }, ["README.md", "dist/AGENTS.md"]); try { @@ -194,7 +201,7 @@ test("rejects a CLI artifact missing the default instructions", () => { }); test("rejects a case variant of the approved default path", () => { - const result = inspect({ "dist/agents.MD": readFileSync("AGENTS_release.md") }, "dist/AGENTS.md"); + const result = inspect({ "dist/agents.MD": readFileSync("scripts/assets/default-agent.md") }, "dist/AGENTS.md"); assert.notEqual(result.status, 0); assert.match(result.stderr, /prohibited repository instructions/); }); diff --git a/docs/release/distribution-policy.json b/scripts/distribution-policy.json similarity index 79% rename from docs/release/distribution-policy.json rename to scripts/distribution-policy.json index 1c77fc959..285062de6 100644 --- a/docs/release/distribution-policy.json +++ b/scripts/distribution-policy.json @@ -27,7 +27,17 @@ "date": "2026-09-13", "authority": "owner approved CLI-only 0.2.1 replacement and retirement of 0.2.0 and separate packages" }, - "standaloneDeferral": "Bun static runtime redistribution and native dependency source/object review must be completed before enabling standalone publication.", + "standaloneDeferral": "Direct installation and standalone self-update remain deferred. The Homebrew-only Node archive tooling does not enable those channels.", + "homebrewApproval": { + "status": "pending", + "redistribution": "pending", + "nativeAcceptance": [], + "quarantine": "pending", + "daytona": "pending", + "publicRepository": "diffexai/diffex", + "tapRepository": "diffexai/homebrew-tap", + "requiredReview": "Node runtime pins and signing-key authority, private npm and complete third-party licenses/source materials, all four retained payloads, public repository visibility and credential scope. Set channels.homebrew to enable only in the reviewed approval commit." + }, "approvedRuntimeAssets": [ "compiled JavaScript from public workspace src inputs", "declarations from those inputs", diff --git a/scripts/fixtures/homebrew-runtime-operations.mjs b/scripts/fixtures/homebrew-runtime-operations.mjs new file mode 100644 index 000000000..25848b324 --- /dev/null +++ b/scripts/fixtures/homebrew-runtime-operations.mjs @@ -0,0 +1,89 @@ +// Executed by the retained bundled Node from an external fixture directory. +// Native desktop bindings are loaded/shape-checked only; no clipboard/keyring reads or writes. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { ModelRuntime, SessionManager } from "@diffexai/diffex"; +import { Text } from "@diffexai/diffex-tui"; + +// This script is copied to /external-fixture, beside the private +// cellar and fresh user state. Arguments/environment may confirm that contract, +// but must never select arbitrary SDK credential, session or executable paths. +const fixtureDirectory = realpathSync(dirname(fileURLToPath(import.meta.url))); +assert.equal(fixtureDirectory, dirname(fileURLToPath(import.meta.url)), "Fixture directory must be physical"); +const root = dirname(fixtureDirectory); +const payload = join(root, "cellar/libexec/diffex"); +assert.equal(process.argv.length, 3, "Expected only the retained payload argument"); +assert.equal(resolve(process.argv[2]), payload, "Payload argument must name the isolated cellar"); +const agentDir = join(root, "agent"); +const project = join(root, "project"); +assert.equal(process.env.DIFFEX_CODING_AGENT_DIR, agentDir, "Agent directory must be isolated"); +assert.equal(realpathSync(process.cwd()), project, "Working directory must be the isolated project"); +assert.equal(process.env.DIFFEX_OFFLINE, "1"); +assert.equal(process.env.DIFFEX_NO_LOCAL_LLM, "1"); +for (const directory of [payload, agentDir, project]) { + assert(directory.startsWith(root + sep), "Fixture directory escapes its root"); + assert.equal(realpathSync(directory), directory, "Fixture directories must not traverse links"); + assert(statSync(directory).isDirectory()); +} +// Earlier CLI listing checks may already have created isolated cache state. +// An exclusive private directory gives these SDK operations fresh state without +// reading any previous credentials/models or accepting a pre-existing link. +const stateDirectory = join(agentDir, "runtime-fixture"); +mkdirSync(stateDirectory, { mode: 0o700 }); +const packageRoot = join(payload, "app/node_modules/@diffexai/diffex"); +assert.equal(realpathSync(packageRoot), packageRoot, "CLI package must remain inside the payload"); +const cliRequire = createRequire(join(packageRoot, "package.json")); +const fixtureRequire = createRequire(import.meta.url); +const runtime = await ModelRuntime.create({ + authPath: join(stateDirectory, "auth.json"), + modelsPath: join(stateDirectory, "models.json"), + refreshOnCreate: false, +}); +assert(runtime); + +const clipboard = cliRequire("@mariozechner/clipboard"); +assert.equal(typeof clipboard.getText, "function"); +assert.equal(typeof clipboard.setText, "function"); +const keyring = cliRequire("@napi-rs/keyring"); +assert.equal(typeof keyring.Entry, "function"); +assert.equal(typeof keyring.Entry.prototype.getPassword, "function"); +assert.equal(typeof keyring.Entry.prototype.setPassword, "function"); +assert.equal(typeof keyring.AsyncEntry.prototype.getSecret, "function"); +assert.equal(typeof keyring.findCredentialsAsync, "function"); + +const photon = cliRequire("@silvia-odwyer/photon-node"); +const image = new photon.PhotonImage(new Uint8Array([0, 0, 0, 255]), 1, 1); +try { + assert.equal(image.get_width(), 1); + assert.equal(image.get_height(), 1); +} finally { image.free(); } +assert(new Text("homebrew native acceptance", 0, 0).render(40).join("\n").includes("homebrew native acceptance")); +if (process.platform === "darwin") { + const tuiRoot = dirname(dirname(fixtureRequire.resolve("@diffexai/diffex-tui"))); + const helper = fixtureRequire(join(tuiRoot, "native/darwin/prebuilds", `darwin-${process.arch}`, "darwin-modifiers.node")); + assert.equal(typeof helper.isModifierPressed, "function"); +} + +const session = SessionManager.create(project, join(stateDirectory, "export-session")); +session.appendMessage({ role: "user", content: "homebrew export", timestamp: Date.now() }); +session.appendMessage({ + role: "assistant", content: [{ type: "text", text: "homebrew exported reply" }], + api: "openai-completions", provider: "fixture", model: "fixture", + usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "stop", timestamp: Date.now(), +}); +const html = join(project, "homebrew export.html"); +execFileSync(process.execPath, [join(packageRoot, "dist/cli.js"), "--export", session.getSessionFile(), html], { + env: process.env, timeout: 30_000, stdio: ["ignore", "pipe", "pipe"], +}); +const exported = readFileSync(html, "utf8"); +const embedded = exported.match(/