diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 917f77dc2..8b16e7372 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -11,9 +11,10 @@ ship a TestFlight build. This is a **GitHub desktop + local ASC iOS release flow**. Desktop releases must use the repository GitHub Actions release workflow so macOS updater assets -are produced reproducibly as per-arch ZIP/DMG artifacts. This Mac may still run -checks, create release docs/tags, monitor and recover the workflow, and build -and upload iOS/TestFlight releases through ASC. +are produced reproducibly as per-arch ZIP/DMG artifacts, and so the signed +Windows installer is produced on a Windows runner this Mac cannot provide. This +Mac may still run checks, create release docs/tags, monitor and recover the +workflow, and build and upload iOS/TestFlight releases through ASC. A **preflight** is a cheap check that runs before expensive build/upload work. Use preflights to catch release blockers while fixes can still be committed @@ -42,7 +43,13 @@ without burning a notarization, TestFlight upload, or build number. crash Squirrel.Mac during in-app update. - **Do not publish broken updater metadata.** Before making a desktop release public/latest, verify `latest-mac.yml` references assets that exist and that - the expected arm64/x64 DMGs and ZIPs are present. + the expected arm64/x64 DMGs and ZIPs are present. When Windows is enabled, + apply the same rule to `latest.yml` and the Windows installer. +- **Do not publish a half-platform release.** The Windows gate and the Windows + assets must agree. If `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is `1` the draft + must carry Windows assets; if it is not `1` the draft must carry none. Either + mismatch means the workflow did not do what you think it did, so keep the + release draft/private and investigate before publishing. - **Do not discover obvious release blockers after upload.** Preflight iOS App Clip packaging metadata before starting the expensive mobile phase. - **Do not wait forever.** If GitHub notarization or TestFlight processing @@ -55,13 +62,24 @@ This release lane runs on an Apple Silicon Mac (`arm64`), but desktop release artifacts are produced remotely by GitHub Actions. Treat local desktop packaging scripts as diagnostic/recovery tools only. -Desktop updater correctness requires: +Desktop updater correctness requires, on macOS: - `latest-mac.yml` - one arm64 ZIP and one x64 ZIP referenced by that file - one arm64 DMG and one x64 DMG - no universal ZIP in the updater feed +and, when `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is `1`, additionally on Windows: + +- `latest.yml` +- one `ADE--win-x64.exe` installer referenced by that file +- the matching `ADE--win-x64.exe.blockmap` + +Windows builds fresh on the tag alongside macOS. There is one repository +variable, `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED`; it decides whether the release +carries Windows at all. Read it before verifying assets, because it determines +which of the two asset matrices below is correct. + ## State and Locking Create a state file before mutating release state: @@ -80,7 +98,7 @@ Track: ```json { - "desktop": { "needed": false, "version": null, "tag": null, "lastTag": null }, + "desktop": { "needed": false, "version": null, "tag": null, "lastTag": null, "platforms": null }, "ios": { "needed": false, "marketingVersion": null, "buildNumber": null, "lastTag": null }, "phase": "detect|docs|desktop|ios|verify|done|blocked", "notes": [] @@ -124,6 +142,7 @@ relevant preflights pass. ```bash test -f .github/workflows/release.yml test -f .github/workflows/release-core.yml + test -f .github/workflows/release-publish.yml gh workflow view release.yml --repo arul28/ADE ``` @@ -132,12 +151,31 @@ relevant preflights pass. - `.github/workflows/release-core.yml` builds `dist:mac:arm64:signed`. - `.github/workflows/release-core.yml` builds `dist:mac:x64:signed`. + - `.github/workflows/release-core.yml` builds `dist:win:signed` in + `build-win-release`. - The publish job merges per-arch manifests into one `latest-mac.yml`. + - The publish job attaches the Windows installer, its `.blockmap`, and + `latest.yml` when the Windows gate is on. If the workflow has been changed to publish universal updater ZIPs, stop and fix the workflow before releasing. -7. For iOS releases, preflight App Clip packaging metadata before archiving: +7. For desktop releases, resolve the expected platform matrix before tagging. + This decides what the draft must contain in Phase 4: + + ```bash + gh variable get ADE_WINDOWS_PUBLIC_RELEASE_ENABLED --repo arul28/ADE 2>/dev/null || echo "unset" + ``` + + - `1` means the release must carry macOS **and** Windows assets. Record + `platforms=mac,win`. + - Anything else, including unset, means macOS only. Record `platforms=mac`. + + Windows signing is fail-closed: if the gate is `1` and the signing secrets + are missing, the `verify` job stops the run in about a minute. Do not + "fix" that by clearing the gate mid-release; fix the secrets or stop. + +8. For iOS releases, preflight App Clip packaging metadata before archiving: ```bash xcodebuild -showBuildSettings \ @@ -333,9 +371,18 @@ Expected shape: - runtime/resource jobs run first - `arm64 mac release` and `x64 mac release` build/sign/notarize independently -- `publish-release` merges the per-arch updater manifests and creates the draft +- `build-win-release` builds/signs/validates Windows independently, in parallel + with the mac jobs, when `platforms` includes `win`. With the gate off it is + skipped, and a skipped Windows job does not block the mac release. +- `publish-release` (in `release-publish.yml`, called by `release.yml` after + `run-release` succeeds) merges the per-arch updater manifests and creates the + draft - `update-brew-tap` runs after publication +If `platforms=mac,win` and `build-win-release` did not run, stop. The gate and +the run disagree, and publishing would ship a macOS-only release under a +version that is supposed to carry Windows. + ### Retry policy Do not start duplicate full release workflows. @@ -376,13 +423,51 @@ gh release download "v" --repo arul28/ADE \ cat ".ade/tmp/release-v-verify/latest-mac.yml" ``` -Required assets: +When `platforms` includes `win`, also pull the Windows updater feed: + +```bash +gh release download "v" --repo arul28/ADE \ + --pattern latest.yml \ + --dir ".ade/tmp/release-v-verify" \ + --clobber +cat ".ade/tmp/release-v-verify/latest.yml" +``` + +Required assets, always: - `ADE--arm64.dmg` - `ADE--arm64.zip` - `ADE--x64.dmg` - `ADE--x64.zip` - `latest-mac.yml` +- `install.sh` +- `SHA256SUMS` +- `ade-darwin-arm64`, `ade-darwin-x64`, `ade-linux-arm64`, `ade-linux-x64`, and + the matching `.native.tar.gz` for each + +Required additionally when `platforms` includes `win`: + +- `ADE--win-x64.exe` +- `ADE--win-x64.exe.blockmap` +- `latest.yml` +- `install.ps1` +- `ade-win32-x64.exe` +- `ade-win32-x64.native.tar.gz` + +Gate/asset agreement is a hard check, in both directions: + +```bash +WINDOWS_GATE="$(gh variable get ADE_WINDOWS_PUBLIC_RELEASE_ENABLED --repo arul28/ADE 2>/dev/null || echo unset)" +WINDOWS_ASSETS="$(gh release view "v" --repo arul28/ADE --json assets \ + --jq '[.assets[].name | select(test("win-x64|win32-x64|^latest\\.yml$|^install\\.ps1$"))] | length')" +echo "gate=$WINDOWS_GATE windows_assets=$WINDOWS_ASSETS" +``` + +- `gate=1` and `windows_assets=0` means the Windows build silently did not + contribute. Stop; keep the release draft/private. +- `gate` not `1` and `windows_assets` greater than `0` means Windows assets + reached a release that was not supposed to carry them. Stop; keep the release + draft/private. Also verify: @@ -391,6 +476,12 @@ Also verify: - no updater ZIP is suspiciously huge; a ZIP over about 900 MB needs human review because Squirrel.Mac can crash while handling oversized updater ZIPs. - every `latest-mac.yml` referenced ZIP exists in the release assets. +- when Windows is in scope, `latest.yml` references the uploaded + `ADE--win-x64.exe`, and that installer and its `.blockmap` both + exist in the release assets. +- `SHA256SUMS` lists every published standalone runtime asset, including the + `ade-win32-x64` entries when Windows is in scope, and lists nothing that is + not published. ### Publish public/latest @@ -597,6 +688,15 @@ Desktop: until fixed. - If `latest-mac.yml` references a universal ZIP, keep the release draft/private and fix the GitHub workflow. Do not publish the release. +- If `latest.yml` is missing, or references an installer that is not in the + release assets, keep the release draft/private. Windows in-app update reads + that file; a broken feed strands installed Windows users. +- If the Windows build fails, the draft is not created at all while the gate is + on, by design. Fix the failure and rerun; do not clear + `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` to force a macOS-only draft under a + version that was announced as carrying Windows. +- If the Windows gate and the published Windows assets disagree in either + direction, keep the release draft/private and reconcile before publishing. iOS: @@ -613,8 +713,12 @@ iOS: Report: - desktop scope decision and tag +- the resolved desktop platform matrix (`mac` or `mac,win`) and the + `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` value it came from - GitHub Release URL and asset count - whether `latest-mac.yml` references only present assets +- when Windows is in scope, whether `latest.yml` references only present assets + and whether the gate and the published Windows assets agreed - iOS marketing/build number - TestFlight build ID - group membership verification diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e892af225..e7626d5a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: branches: [main] pull_request: branches: [main] + # Stacked PRs target their parent branch, not main, so the filter above + # never fires for them and they accumulate no ci-pass check run. That + # matters because release-core.yml's verify job requires ci-pass on the + # exact SHA it builds, which made a signed proof build of a stacked + # branch impossible. Dispatching by ref produces the same check runs on + # the same SHA, so the release gate stays honest rather than relaxed. + workflow_dispatch: permissions: contents: read @@ -62,7 +69,27 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + # On pull_request the action scans only the PR range. On workflow_dispatch + # it scans the entire history instead, which re-reports every already + # accepted false positive: gitleaks fingerprints are commit-scoped, so a + # finding waived in .gitleaksignore reappears under the SHA of every later + # commit that touched the same line. Scanning main..HEAD gives a dispatched + # run exactly the scope a PR run has, so the same code is judged the same + # way however CI was started. + # gitleaks-action exposes no way to narrow its commit range, so a + # dispatched run invokes gitleaks directly with the same pinned version + # the action uses. --log-opts limits the walk to this branch's own + # commits, matching what a pull_request run would scan. + - name: Scan this branch's own commits + if: github.event_name == 'workflow_dispatch' + run: | + set -euo pipefail + git fetch --no-tags origin main + curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v8.24.3/gitleaks_8.24.3_linux_x64.tar.gz" \ + | tar -xz gitleaks + ./gitleaks detect --redact -v --exit-code=2 --log-opts="origin/main..HEAD" - uses: gitleaks/gitleaks-action@v2 + if: github.event_name != 'workflow_dispatch' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -356,12 +383,19 @@ jobs: include: - target: darwin-arm64 os: macos-15 + binary: ade-darwin-arm64 - target: darwin-x64 os: macos-15-intel + binary: ade-darwin-x64 - target: linux-x64 os: ubuntu-latest + binary: ade-linux-x64 - target: linux-arm64 os: ubuntu-24.04-arm + binary: ade-linux-arm64 + - target: win32-x64 + os: windows-latest + binary: ade-win32-x64.exe steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -386,8 +420,9 @@ jobs: run: cd apps/ade-cli && npm run build:static -- --target ${{ matrix.target }} - name: Smoke test ADE runtime binary + shell: bash run: | - apps/ade-cli/dist-static/ade-${{ matrix.target }} --version + apps/ade-cli/dist-static/${{ matrix.binary }} --version archive="apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz" archive_listing="$RUNNER_TEMP/ade-${{ matrix.target }}-native-files.txt" tar -tzf "$archive" > "$archive_listing" @@ -402,7 +437,7 @@ jobs: with: name: ade-runtime-${{ matrix.target }} path: | - apps/ade-cli/dist-static/ade-${{ matrix.target }} + apps/ade-cli/dist-static/${{ matrix.binary }} apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz if-no-files-found: error compression-level: 0 @@ -459,6 +494,18 @@ jobs: - name: Test Windows sync-port holder identification run: cd apps/ade-cli && npx vitest run src/services/sync/sharedSyncListener.test.ts + # cli.test.ts carries the win32-gated headless-RPC named-pipe case. It is + # green only once this layer's CLI fixes compose, so the step lives here + # rather than in the foundation layer. + - name: Test Windows CLI contracts + run: cd apps/ade-cli && npx vitest run src/cli.test.ts + + # Daemon supervision, restart, and version/role-compatibility. Spawns real + # `ade serve` daemons over the platform transport, so this is the only gate + # that exercises the always-on-brain contract on a named pipe. + - name: Test Windows stdio RPC daemon bridge contracts + run: cd apps/ade-cli && npx vitest run src/stdioRpcDaemon.test.ts + # `trustedWindowsTools` is a security control whose only substantive case # is win32-gated, so before this step it ran on no runner at all. The # credential store and the `ade://` deeplink command-injection guard are @@ -480,6 +527,7 @@ jobs: - name: Test Windows path, spawn, window, and update contracts run: >- cd apps/desktop && npx vitest run + src/main/services/appControl/appControlLaunchCommand.test.ts src/main/services/appControl/appControlService.test.ts src/main/services/shared/processExecution.test.ts src/main/services/updates/autoUpdateService.test.ts @@ -511,6 +559,79 @@ jobs: src/main/services/sync/deviceRegistryService.test.ts src/main/services/sync/syncHostService.test.ts src/main/services/sync/syncService.test.ts + + package-win: + needs: build-runtime-binaries + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + apps/desktop/package-lock.json + apps/ade-cli/package-lock.json + + - name: Install desktop dependencies + run: cd apps/desktop && npm ci + + - name: Install ADE CLI dependencies + run: cd apps/ade-cli && npm ci + + - name: Download ADE runtime sidecars + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: apps/desktop/resources/runtime + merge-multiple: true + + - name: Validate Windows release contract + run: npm --prefix apps/desktop run test:win:release-contract + + - name: Build unsigned Stable Windows preview + env: + ADE_RELEASE_REPOSITORY: ${{ github.repository }} + ELECTRON_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron + ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron-builder + run: cd apps/desktop && npm run dist:win + + - name: Record Stable installer + shell: pwsh + run: | + $installers = @(Get-ChildItem "apps/desktop/release/ADE-[0-9]*-win-x64.exe" -File) + if ($installers.Count -ne 1) { throw "Expected exactly one Stable Windows installer, found $($installers.Count)." } + "ADE_STABLE_INSTALLER=$($installers[0].FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Build unsigned Beta Windows preview + env: + ADE_PACKAGE_CHANNEL: beta + ADE_RELEASE_REPOSITORY: ${{ github.repository }} + ELECTRON_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron + ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron-builder + run: cd apps/desktop && npm run dist:win + + - name: Test Stable and Beta installed-product lifecycles + shell: pwsh + run: | + $betaInstallers = @(Get-ChildItem "apps/desktop/release/ADE-Beta-[0-9]*-win-x64.exe" -File) + if ($betaInstallers.Count -ne 1) { throw "Expected exactly one Beta Windows installer, found $($betaInstallers.Count)." } + & apps/desktop/scripts/windows-installed-product-smoke.ps1 ` + -InstallerPath $env:ADE_STABLE_INSTALLER ` + -CompanionInstallerPath $betaInstallers[0].FullName + + - name: Upload Windows preview artifacts + uses: actions/upload-artifact@v4 + with: + name: ade-win-preview-${{ github.sha }} + path: | + apps/desktop/release/*.exe + apps/desktop/release/*.exe.blockmap + apps/desktop/release/latest.yml + if-no-files-found: error + retention-days: 14 validate-docs: needs: install runs-on: ubuntu-latest @@ -561,6 +682,7 @@ jobs: - build - build-runtime-binaries - windows-foundation + - package-win - validate-docs runs-on: ubuntu-latest steps: diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index afbbbb48f..fec62471e 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,5 +1,10 @@ name: Prepare release +# Platform-neutral, non-publishing validation run. It never creates or updates a +# GitHub Release. Set windows_proof when the run is being used to collect the +# signed Windows exact-SHA proof; the Windows-specific preconditions are then +# asserted before anything is built. + on: workflow_dispatch: inputs: @@ -7,6 +12,19 @@ on: description: Version to release. Accepts 1.2.3 or v1.2.3. required: true type: string + target_sha: + description: Exact 40-character commit SHA on main to validate. + required: true + type: string + windows_proof: + description: >- + Collect clean-host Windows proof evidence. Builds and signs Windows + even while ADE_WINDOWS_PUBLIC_RELEASE_ENABLED is off, and emits the + exact-SHA proof bundle. Leave false for the ordinary dry run, which + still builds Windows whenever the publication gate is on. + required: false + default: false + type: boolean permissions: actions: read @@ -20,16 +38,46 @@ jobs: tag_name: ${{ steps.resolve.outputs.tag_name }} target_sha: ${{ steps.resolve.outputs.target_sha }} steps: + # Opt-in evidence collection. It never depends on a repository variable + # state, so proof can be collected before Windows is enabled and again as + # a regression check after it is. This workflow never publishes either way. + - name: Prepare signed Windows proof mode + if: ${{ inputs.windows_proof }} + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} + run: | + set -euo pipefail + + if [ -z "$AZURE_TENANT_ID" ] || [ -z "$AZURE_CLIENT_ID" ] || [ -z "$AZURE_CLIENT_SECRET" ]; then + echo "::error::Signed Windows proof requires the AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET secrets." + exit 1 + fi + if [ -z "$WINDOWS_SIGNING_EXPECTED_SUBJECT" ]; then + echo "::error::Signed Windows proof requires the WINDOWS_SIGNING_EXPECTED_SUBJECT secret." + exit 1 + fi + if [ -n "$WINDOWS_SIGNING_EXPECTED_THUMBPRINT" ]; then + echo "::error::WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline. Delete the secret and pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead." + exit 1 + fi + - uses: actions/checkout@v4 with: - ref: main + ref: ${{ inputs.target_sha }} fetch-depth: 0 - name: Resolve version and target commit id: resolve env: INPUT_VERSION: ${{ inputs.version }} + INPUT_TARGET_SHA: ${{ inputs.target_sha }} run: | + set -euo pipefail + version="$(printf '%s' "$INPUT_VERSION" | tr -d '[:space:]')" if [ -z "$version" ]; then echo "::error::Version input cannot be empty." @@ -46,8 +94,20 @@ jobs: exit 1 fi + if ! printf '%s' "$INPUT_TARGET_SHA" | grep -Eq '^[0-9a-fA-F]{40}$'; then + echo "::error::target_sha must be the exact 40-character commit SHA approved for release." + exit 1 + fi + + requested_sha="$(printf '%s' "$INPUT_TARGET_SHA" | tr '[:upper:]' '[:lower:]')" + resolved_sha="$(git rev-parse HEAD)" + if [ "$resolved_sha" != "$requested_sha" ]; then + echo "::error::Checked out $resolved_sha instead of requested commit $requested_sha." + exit 1 + fi + echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT" - echo "target_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "target_sha=$resolved_sha" >> "$GITHUB_OUTPUT" validate: needs: resolve @@ -55,5 +115,5 @@ jobs: with: release_tag: ${{ needs.resolve.outputs.tag_name }} target_ref: ${{ needs.resolve.outputs.target_sha }} - publish: false + windows_proof: ${{ inputs.windows_proof }} secrets: inherit diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index d83b322e1..895fd92ba 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -11,16 +11,37 @@ on: description: Commit SHA or ref to build and release. required: true type: string - publish: - description: Whether to upload validated artifacts to a draft GitHub release. + windows_proof: + description: >- + Collect clean-host Windows proof evidence. Builds Windows even while + ADE_WINDOWS_PUBLIC_RELEASE_ENABLED is off and emits the exact-SHA + proof manifest bundle. Deliberate evidence collection only; it does + not gate ordinary releases. required: false default: false type: boolean - + outputs: + runtime_result: + description: Aggregate result of the build-runtime-binaries matrix. + value: ${{ jobs.build-results.outputs.runtime_result }} + mac_result: + description: Aggregate result of the build-mac-release matrix. + value: ${{ jobs.build-results.outputs.mac_result }} + windows_result: + description: >- + Result of build-win-release. 'skipped' when the Windows publication + gate is off and no proof run was requested. + value: ${{ jobs.build-results.outputs.windows_result }} + +# No job in this workflow may request more than these permissions. It is called +# by prepare-release.yml with a read-only token, and GitHub rejects a called +# workflow at parse time if any nested job requests more than the caller grants, +# regardless of whether that job would ever run. Publishing lives in +# release-publish.yml for exactly this reason. permissions: actions: read checks: read - contents: write + contents: read jobs: verify: @@ -31,12 +52,47 @@ jobs: ref: ${{ inputs.target_ref }} fetch-depth: 0 + - name: Validate release tag and target binding + env: + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + if ! printf '%s' "$RELEASE_TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::Release tag must use the canonical v1.2.3 or v1.2.3-prerelease form." + exit 1 + fi + + target_sha="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then + git fetch --force --no-tags origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + tag_sha="$(git rev-list -n 1 "refs/tags/$RELEASE_TAG" | tr '[:upper:]' '[:lower:]')" + if [ "$tag_sha" != "$target_sha" ]; then + echo "::error::Release tag $RELEASE_TAG resolves to $tag_sha, not approved target $target_sha." + exit 1 + fi + fi + + # Skipped only for windows_proof, which exists to collect clean-host + # evidence for a commit that has NOT merged yet -- proving the packaging + # and signing of a change is the whole point, and requiring main first + # would make that impossible. That mode is reachable only from + # prepare-release.yml, which holds contents: read and cannot publish, so + # nothing can ship from a commit this step did not vet. Every publishing + # path leaves windows_proof false and is still gated here. - name: Ensure release commit points to main + if: ${{ !inputs.windows_proof }} run: | git fetch origin main:refs/remotes/origin/main git merge-base --is-ancestor HEAD refs/remotes/origin/main + # Skipped for windows_proof only, alongside the main-ancestor check above + # and for the same reason: that mode exists to package and sign a commit + # that has not merged, and it runs solely from prepare-release.yml, which + # holds contents: read and cannot create a release or tag. Nothing can + # ship from a commit this step did not vet. Every publishing path leaves + # windows_proof false and is still gated here. - name: Ensure CI passed for release commit + if: ${{ !inputs.windows_proof }} env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} @@ -68,6 +124,43 @@ jobs: echo "ci-pass succeeded for $TARGET_REF: $url" + # Windows builds fresh on the tag, the same way macOS does. There is one + # maintainer-facing switch, ADE_WINDOWS_PUBLIC_RELEASE_ENABLED, plus the + # deliberate windows_proof dispatch input for clean-host evidence runs. + # Fail here, a minute in, rather than after a full Windows package build. + - name: Validate Windows release configuration + env: + PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} + WINDOWS_PROOF: ${{ inputs.windows_proof }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} + run: | + set -euo pipefail + + if [ "$PUBLISH_WINDOWS" != "1" ] && [ "$WINDOWS_PROOF" != "true" ]; then + echo "Windows release is disabled for this run. Set ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 to publish Windows assets." + exit 0 + fi + + if [ -z "$AZURE_TENANT_ID" ] || [ -z "$AZURE_CLIENT_ID" ] || [ -z "$AZURE_CLIENT_SECRET" ]; then + echo "::error::Windows releases require the AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET secrets." + exit 1 + fi + if [ -z "$WINDOWS_SIGNING_EXPECTED_SUBJECT" ]; then + echo "::error::Windows releases require the WINDOWS_SIGNING_EXPECTED_SUBJECT secret to pin the approved publisher." + exit 1 + fi + # Azure Artifact Signing renews the certificate daily and expires it + # after 72 hours, so a thumbprint pin would start failing releases + # within days. Refuse the secret rather than ignore it. + if [ -n "$WINDOWS_SIGNING_EXPECTED_THUMBPRINT" ]; then + echo "::error::WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline. Delete the secret and pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead." + exit 1 + fi + build-mac-release: needs: - verify @@ -75,7 +168,8 @@ jobs: # One job per arch, in parallel on native runners (arm64 -> macos-15, # x64 -> macos-15-intel). Wall-clock ~= one arch's build+notarize instead of # both serialized. Each job signs+notarizes+staples its own arch; the - # per-arch updater manifests are merged in publish-release. + # per-arch updater manifests are merged by the publish-release job in + # release-publish.yml. strategy: fail-fast: false matrix: @@ -242,78 +336,223 @@ jobs: apps/desktop/release/latest-mac-${{ matrix.arch }}.yml if-no-files-found: error - # Windows release builds are disabled for now — ADE ships macOS-only releases. - # To re-enable: uncomment this job, add `build-win-release` back to - # publish-release `needs`, and restore the win blocks in the publish job - # (artifact download, manifest validation, upload list). - # build-win-release: - # needs: - # - verify - # - build-runtime-binaries - # runs-on: windows-latest - # concurrency: - # group: release-${{ inputs.release_tag }}-win - # cancel-in-progress: true - # steps: - # - uses: actions/checkout@v4 - # with: - # ref: ${{ inputs.target_ref }} - # fetch-depth: 0 - # - # - uses: actions/setup-node@v4 - # with: - # node-version: 22 - # cache: npm - # cache-dependency-path: | - # apps/desktop/package-lock.json - # apps/ade-cli/package-lock.json - # - # - name: Install desktop dependencies - # run: cd apps/desktop && npm ci - # - # - name: Install ADE CLI dependencies - # run: cd apps/ade-cli && npm ci - # - # - name: Download ADE runtime binaries - # uses: actions/download-artifact@v4 - # with: - # pattern: ade-runtime-* - # path: apps/desktop/resources/runtime - # merge-multiple: true - # - # - name: Materialize ADE runtime resources - # env: - # ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}\apps\desktop\resources\runtime - # run: cd apps/desktop && npm run materialize:runtime-resources - # - # - name: Stamp release version - # env: - # ADE_RELEASE_TAG: ${{ inputs.release_tag }} - # run: cd apps/desktop && npm run version:release - # - # - name: Reset release output - # shell: pwsh - # run: | - # Remove-Item -Recurse -Force apps/desktop/release, apps/desktop/.cache -ErrorAction SilentlyContinue - # New-Item -ItemType Directory -Path apps/desktop/.cache | Out-Null - # - # - name: Build and validate Windows release - # env: - # ELECTRON_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron - # ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron-builder - # CSC_LINK: ${{ ((secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK) && (secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD)) && (secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK) || '' }} - # CSC_KEY_PASSWORD: ${{ ((secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK) && (secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD)) && (secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD) || '' }} - # run: cd apps/desktop && npm run dist:win - # - # - name: Upload validated Windows artifacts to workflow run - # uses: actions/upload-artifact@v4 - # with: - # name: ade-win-release-${{ inputs.release_tag }} - # path: | - # apps/desktop/release/*.exe - # apps/desktop/release/*.exe.blockmap - # apps/desktop/release/latest.yml - # if-no-files-found: error + build-win-release: + # Same shape as build-mac-release: the tagged commit is built, signed and + # validated in-run, and its output is published by release-publish.yml. The + # windows_proof input additionally builds Windows while the publication gate + # is still off, so clean-host evidence can be collected deliberately. + if: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' || inputs.windows_proof }} + needs: + - verify + - build-runtime-binaries + runs-on: windows-latest + concurrency: + group: release-${{ inputs.release_tag }}-win-x64 + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + apps/desktop/package-lock.json + apps/ade-cli/package-lock.json + + - name: Install desktop dependencies + run: cd apps/desktop && npm ci + + - name: Install ADE CLI dependencies + run: cd apps/ade-cli && npm ci + + - name: Require Windows Authenticode signing secrets + shell: pwsh + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} + run: | + if ([string]::IsNullOrWhiteSpace($env:AZURE_TENANT_ID) -or [string]::IsNullOrWhiteSpace($env:AZURE_CLIENT_ID) -or [string]::IsNullOrWhiteSpace($env:AZURE_CLIENT_SECRET)) { + throw "Public Windows releases require AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET." + } + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_EXPECTED_SUBJECT)) { + throw "Public Windows releases require WINDOWS_SIGNING_EXPECTED_SUBJECT." + } + if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_EXPECTED_THUMBPRINT)) { + throw "WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline; pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead." + } + + - name: Download ADE runtime binaries + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: apps/desktop/resources/runtime + merge-multiple: true + + - name: Materialize ADE runtime resources + env: + ADE_RUNTIME_ARTIFACTS_DIR: ${{ github.workspace }}\apps\desktop\resources\runtime + run: cd apps/desktop && npm run materialize:runtime-resources + + - name: Stamp release version + env: + ADE_RELEASE_TAG: ${{ inputs.release_tag }} + run: cd apps/desktop && npm run version:release + + - name: Reset release output + shell: pwsh + run: | + Remove-Item -Recurse -Force apps/desktop/release, apps/desktop/.cache -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path apps/desktop/.cache | Out-Null + + - name: Build and validate Windows release + env: + ELECTRON_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron + ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron-builder + ADE_RELEASE_REPOSITORY: ${{ github.repository }} + ADE_POSTHOG_PROJECT_TOKEN: ${{ secrets.ADE_POSTHOG_PROJECT_TOKEN }} + ADE_POSTHOG_HOST: ${{ secrets.ADE_POSTHOG_HOST }} + # Azure Artifact Signing service principal. EnvironmentCredential + # reads exactly these three names and is tried ahead of the managed + # identity probe that a GitHub-hosted runner cannot answer. + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + run: cd apps/desktop && npm run dist:win:signed + + - name: Test installed signed Windows product lifecycle + shell: pwsh + run: | + $installers = @(Get-ChildItem "apps/desktop/release/ADE-[0-9]*-win-x64.exe" -File) + if ($installers.Count -ne 1) { throw "Expected exactly one signed Windows installer, found $($installers.Count)." } + & apps/desktop/scripts/windows-installed-product-smoke.ps1 -InstallerPath $installers[0].FullName + + - name: Upload validated Windows release artifacts + uses: actions/upload-artifact@v4 + with: + name: ade-win-release-${{ inputs.release_tag }} + path: | + apps/desktop/release/ADE-*-win-x64.exe + apps/desktop/release/ADE-*-win-x64.exe.blockmap + apps/desktop/release/latest.yml + if-no-files-found: error + + # Everything below is deliberate clean-host evidence collection. It builds + # the redacted proof bundle and its exact-SHA manifest, and never gates an + # ordinary release. The machine-checkable Windows gates all ran above: + # the job-level signing-secret requirement, --require-signing in + # run-electron-builder.mjs, --require-signed Authenticode verification in + # validate-win-artifacts.mjs, and the installed-product lifecycle smoke. + - name: Stage standalone runtime proof assets + if: ${{ inputs.windows_proof }} + shell: pwsh + run: | + $releaseDir = "apps/desktop/release" + Copy-Item -LiteralPath "apps/ade-cli/scripts/install-runtime.sh" -Destination "$releaseDir/install.sh" + Copy-Item -LiteralPath "apps/ade-cli/scripts/install-runtime.ps1" -Destination "$releaseDir/install.ps1" + $runtimeFiles = @( + "ade-darwin-arm64", + "ade-darwin-arm64.native.tar.gz", + "ade-darwin-x64", + "ade-darwin-x64.native.tar.gz", + "ade-linux-arm64", + "ade-linux-arm64.native.tar.gz", + "ade-linux-x64", + "ade-linux-x64.native.tar.gz", + "ade-win32-x64.exe", + "ade-win32-x64.native.tar.gz" + ) + $actualRuntimeFiles = @( + Get-ChildItem -LiteralPath "apps/desktop/resources/runtime" -File -Filter "ade-*" | + ForEach-Object Name | + Sort-Object + ) + $unexpectedRuntimeFiles = @($actualRuntimeFiles | Where-Object { $_ -notin $runtimeFiles }) + $missingRuntimeFiles = @($runtimeFiles | Where-Object { $_ -notin $actualRuntimeFiles }) + if ($unexpectedRuntimeFiles.Count -gt 0 -or $missingRuntimeFiles.Count -gt 0) { + throw "Runtime artifact inventory mismatch. Missing: $($missingRuntimeFiles -join ', '); unexpected: $($unexpectedRuntimeFiles -join ', ')." + } + foreach ($runtimeFile in $runtimeFiles) { + Copy-Item -LiteralPath "apps/desktop/resources/runtime/$runtimeFile" -Destination $releaseDir + } + $checksumFiles = @( + Get-Item -LiteralPath "$releaseDir/install.sh" + Get-Item -LiteralPath "$releaseDir/install.ps1" + $runtimeFiles | ForEach-Object { Get-Item -LiteralPath "$releaseDir/$_" } + ) | Sort-Object Name + $checksumLines = $checksumFiles | ForEach-Object { + $digest = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$digest $($_.Name)" + } + [System.IO.File]::WriteAllLines( + (Join-Path (Resolve-Path $releaseDir) "SHA256SUMS"), + $checksumLines, + [System.Text.UTF8Encoding]::new($false) + ) + + - name: Generate exact-SHA Windows proof manifest + if: ${{ inputs.windows_proof }} + shell: pwsh + run: >- + node apps/desktop/scripts/windows-proof-manifest.mjs create + --output apps/desktop/release/windows-proof-manifest.json + --release-dir apps/desktop/release + --target-sha "${{ inputs.target_ref }}" + --release-tag "${{ inputs.release_tag }}" + --repository "${{ github.repository }}" + --workflow-name "${{ github.workflow }}" + --workflow-run-id "${{ github.run_id }}" + --workflow-run-attempt "${{ github.run_attempt }}" + --workflow-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + - name: Validate exact-SHA Windows build proof + if: ${{ inputs.windows_proof }} + shell: pwsh + run: >- + node apps/desktop/scripts/windows-proof-manifest.mjs validate + --manifest apps/desktop/release/windows-proof-manifest.json + --phase build + --expected-sha "${{ inputs.target_ref }}" + --expected-tag "${{ inputs.release_tag }}" + --expected-run-id "${{ github.run_id }}" + --artifact-root apps/desktop/release + + - name: Upload validated Windows proof bundle + if: ${{ inputs.windows_proof }} + uses: actions/upload-artifact@v4 + with: + name: ade-win-proof-${{ inputs.release_tag }} + # Clean-host evidence is reviewed by a human days or weeks after the + # run. Pinned instead of inherited so a lowered repository or + # organization default cannot expire the bundle mid-review. + # Keep this in sync with docs/playbooks/windows-signed-release.md. + retention-days: 90 + path: | + apps/desktop/release/ADE-*-win-x64.exe + apps/desktop/release/ADE-*-win-x64.exe.blockmap + apps/desktop/release/ade-darwin-arm64 + apps/desktop/release/ade-darwin-arm64.native.tar.gz + apps/desktop/release/ade-darwin-x64 + apps/desktop/release/ade-darwin-x64.native.tar.gz + apps/desktop/release/ade-linux-arm64 + apps/desktop/release/ade-linux-arm64.native.tar.gz + apps/desktop/release/ade-linux-x64 + apps/desktop/release/ade-linux-x64.native.tar.gz + apps/desktop/release/ade-win32-x64.exe + apps/desktop/release/ade-win32-x64.native.tar.gz + apps/desktop/release/install.sh + apps/desktop/release/install.ps1 + apps/desktop/release/SHA256SUMS + apps/desktop/release/latest.yml + apps/desktop/release/windows-proof-manifest.json + if-no-files-found: error build-runtime-binaries: needs: verify @@ -323,12 +562,19 @@ jobs: include: - target: darwin-arm64 os: macos-15 + binary: ade-darwin-arm64 - target: darwin-x64 os: macos-15-intel + binary: ade-darwin-x64 - target: linux-x64 os: ubuntu-latest + binary: ade-linux-x64 - target: linux-arm64 os: ubuntu-24.04-arm + binary: ade-linux-arm64 + - target: win32-x64 + os: windows-latest + binary: ade-win32-x64.exe runs-on: ${{ matrix.os }} concurrency: group: release-${{ inputs.release_tag }}-runtime-${{ matrix.target }} @@ -368,6 +614,18 @@ jobs: ADE_POSTHOG_HOST: ${{ secrets.ADE_POSTHOG_HOST }} run: cd apps/ade-cli && npm run build:static -- --target ${{ matrix.target }} + - name: Sign and validate standalone Windows runtime + if: ${{ matrix.target == 'win32-x64' && (vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' || inputs.windows_proof) }} + shell: pwsh + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + run: | + & apps/ade-cli/scripts/sign-windows-runtime.ps1 ` + -BinaryPath "apps/ade-cli/dist-static/${{ matrix.binary }}" + - name: Materialize runtime notarization API key if: ${{ startsWith(matrix.target, 'darwin-') }} env: @@ -428,8 +686,9 @@ jobs: run: cd apps/ade-cli && npm run notarize:static -- --binary=dist-static/ade-${{ matrix.target }} - name: Smoke test ADE runtime binary + shell: bash run: | - apps/ade-cli/dist-static/ade-${{ matrix.target }} --version + apps/ade-cli/dist-static/${{ matrix.binary }} --version archive="apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz" archive_listing="$RUNNER_TEMP/ade-${{ matrix.target }}-native-files.txt" tar -tzf "$archive" > "$archive_listing" @@ -439,171 +698,56 @@ jobs: exit 1 fi + - name: Assemble signed Windows standalone runtime bundle + if: ${{ matrix.target == 'win32-x64' && (vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' || inputs.windows_proof) }} + shell: bash + run: | + set -euo pipefail + cp apps/ade-cli/scripts/install-runtime.ps1 apps/ade-cli/dist-static/install.ps1 + ( + cd apps/ade-cli/dist-static + sha256sum install.ps1 ade-win32-x64.exe ade-win32-x64.native.tar.gz \ + | LC_ALL=C sort -k2 > SHA256SUMS + sha256sum -c SHA256SUMS + ) + - name: Upload ADE runtime binary uses: actions/upload-artifact@v4 with: name: ade-runtime-${{ matrix.target }} path: | - apps/ade-cli/dist-static/ade-${{ matrix.target }} + apps/ade-cli/dist-static/${{ matrix.binary }} apps/ade-cli/dist-static/ade-${{ matrix.target }}.native.tar.gz + apps/ade-cli/dist-static/install.ps1 + apps/ade-cli/dist-static/SHA256SUMS if-no-files-found: error compression-level: 0 - publish-release: - if: ${{ inputs.publish }} + build-results: + # This workflow deliberately holds no job that can write to the repository, + # so the publish gate cannot be evaluated here (see release-publish.yml). + # Republish the three build results the gate reads as workflow outputs. + # always() so a failed or skipped build is still reported rather than + # collapsing the outputs to empty, which is what lets the caller keep the + # original semantics: with the Windows gate on, a failed or skipped Windows + # build blocks the draft exactly as a failed macOS build does. + if: always() needs: - build-runtime-binaries - build-mac-release + - build-win-release runs-on: ubuntu-latest + outputs: + runtime_result: ${{ needs.build-runtime-binaries.result }} + mac_result: ${{ needs.build-mac-release.result }} + windows_result: ${{ needs.build-win-release.result }} steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.target_ref }} - fetch-depth: 1 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Download per-arch macOS release artifacts - uses: actions/download-artifact@v4 - with: - pattern: ade-mac-release-${{ inputs.release_tag }}-* - path: release-assets/mac - merge-multiple: true - - - name: Merge per-arch updater manifests into latest-mac.yml - run: | - set -euo pipefail - # Each per-arch build uploaded latest-mac-.yml; merge them into a - # single latest-mac.yml whose files[] lists both arch zips (electron-updater - # picks the right one by arch). --ignore-scripts skips the heavy electron - # postinstall — we only need the pure-JS `yaml` dep for the merge. - (cd apps/desktop && npm ci --ignore-scripts) - node apps/desktop/scripts/merge-mac-latest-yml.mjs \ - release-assets/mac/latest-mac-arm64.yml \ - release-assets/mac/latest-mac-x64.yml \ - release-assets/mac/latest-mac.yml - rm -f release-assets/mac/latest-mac-arm64.yml release-assets/mac/latest-mac-x64.yml - - # Windows artifacts are intentionally NOT published right now. The - # standalone runtime assets are published because headless brains and - # mobile-driven recovery updates depend on the same release payloads the - # desktop bundle uploads for remote runtime bootstrap. - # - name: Download Windows release artifacts - # uses: actions/download-artifact@v4 - # with: - # name: ade-win-release-${{ inputs.release_tag }} - # path: release-assets/win - - - name: Download ADE runtime binaries - uses: actions/download-artifact@v4 - with: - pattern: ade-runtime-* - path: release-assets/runtime - merge-multiple: true - - - name: Add standalone runtime installer - run: | - cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh - chmod 755 release-assets/runtime/install.sh - - - name: Generate standalone runtime checksums - run: | - set -euo pipefail - (cd release-assets/runtime && sha256sum install.sh ade-* | LC_ALL=C sort -k2 > SHA256SUMS) - - - name: Validate publish asset manifest - run: | - set -euo pipefail - shopt -s nullglob - - require_file() { - local file="$1" - local label="${2:-$1}" - if [ ! -s "$file" ]; then - echo "::error::Missing or empty $label: $file" - exit 1 - fi - } - - require_glob() { - local pattern="$1" - local label="${2:-$1}" - mapfile -t matches < <(compgen -G "$pattern" || true) - if [ "${#matches[@]}" -eq 0 ]; then - echo "::error::Missing $label matching $pattern" - exit 1 - fi - for file in "${matches[@]}"; do - require_file "$file" "$label" - done - } - - require_glob 'release-assets/mac/*.dmg' 'macOS DMG' - require_glob 'release-assets/mac/*.zip' 'macOS zip' - require_file 'release-assets/mac/latest-mac.yml' 'macOS auto-update metadata' - - # Windows artifacts are not published right now. - # require_glob 'release-assets/win/*.exe' 'Windows installer' - # require_glob 'release-assets/win/*.exe.blockmap' 'Windows blockmap' - # require_file 'release-assets/win/latest.yml' 'Windows auto-update metadata' - require_file 'release-assets/runtime/install.sh' 'standalone runtime installer' - if [ ! -x 'release-assets/runtime/install.sh' ]; then - echo "::error::Standalone runtime installer is not executable." - exit 1 - fi - require_file 'release-assets/runtime/SHA256SUMS' 'standalone runtime checksum manifest' - (cd release-assets/runtime && sha256sum -c SHA256SUMS) - - for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do - require_file "release-assets/runtime/ade-$target" "ADE runtime binary for $target" - require_file "release-assets/runtime/ade-$target.native.tar.gz" "ADE native dependency archive for $target" - archive_listing="$RUNNER_TEMP/ade-$target-native-files.txt" - tar -tzf "release-assets/runtime/ade-$target.native.tar.gz" > "$archive_listing" - grep -q '^\./node_modules/' "$archive_listing" || { - echo "::error::ADE native dependency archive for $target is missing node_modules." - exit 1 - } - done - - - name: Create or update draft GitHub release + - name: Report build results env: - GH_TOKEN: ${{ github.token }} - TAG_NAME: ${{ inputs.release_tag }} - TARGET_REF: ${{ inputs.target_ref }} - GH_REPO: ${{ github.repository }} + RUNTIME_RESULT: ${{ needs.build-runtime-binaries.result }} + MAC_RESULT: ${{ needs.build-mac-release.result }} + WINDOWS_RESULT: ${{ needs.build-win-release.result }} run: | - shopt -s nullglob - # macOS-only, per-arch release surface. The per-arch zips + latest-mac.yml - # are what electron-updater consumes; the per-arch DMGs are the human - # downloads. Blockmaps are intentionally NOT published (they only enable - # differential downloads, which don't help the mac in-memory update path) - # to keep the asset list clean: 2 dmg + 2 zip + latest-mac.yml. - files=( - release-assets/mac/*.dmg - release-assets/mac/*.zip - release-assets/mac/latest-mac.yml - # release-assets/win/*.exe - # release-assets/win/*.exe.blockmap - # release-assets/win/latest.yml - release-assets/runtime/install.sh - release-assets/runtime/SHA256SUMS - release-assets/runtime/ade-* - ) - - if [ "${#files[@]}" -eq 0 ]; then - echo "::error::No release artifacts were found after validation." - exit 1 - fi - - if gh release view "$TAG_NAME" --repo "$GH_REPO" >/dev/null 2>&1; then - gh release upload "$TAG_NAME" "${files[@]}" --repo "$GH_REPO" --clobber - else - gh release create "$TAG_NAME" "${files[@]}" \ - --repo "$GH_REPO" \ - --draft \ - --title "$TAG_NAME" \ - --generate-notes \ - --target "$TARGET_REF" - fi + echo "build-runtime-binaries: $RUNTIME_RESULT" + echo "build-mac-release: $MAC_RESULT" + echo "build-win-release: $WINDOWS_RESULT" diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 000000000..c74985239 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,354 @@ +name: Release publish + +# The only job in the release pipeline that can write to the repository, kept +# deliberately in its own reusable workflow. +# +# release-core.yml is called by two entry points with different intents: +# release.yml, the real release, which grants `contents: write`, and +# prepare-release.yml, the non-publishing validation run, which grants +# `contents: read` on purpose so a dry run provably cannot create a release. +# GitHub validates a called workflow's job permissions statically, at parse +# time, before any job-level `if:` is evaluated, so a job requesting +# `contents: write` anywhere inside release-core.yml makes prepare-release.yml +# fail to load outright ("The nested job 'publish-release' is requesting +# 'contents: write', but is only allowed 'contents: read'"). +# +# Granting write to prepare-release.yml would have fixed the parse error and +# destroyed the property that workflow exists to guarantee. Splitting the +# write-capable job out instead keeps the dry run's token read-only, which is +# the actual enforcement. Do not merge this job back into release-core.yml. +# +# The publish gate that used to live on this job's `if:` now lives in +# release.yml, reconstructed from release-core.yml's build-result outputs. + +on: + workflow_call: + inputs: + release_tag: + description: Release tag to publish. + required: true + type: string + target_ref: + description: Commit SHA or ref the release must resolve to. + required: true + type: string + +permissions: + actions: read + contents: write + +jobs: + publish-release: + permissions: + actions: read + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 1 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Download per-arch macOS release artifacts + uses: actions/download-artifact@v4 + with: + pattern: ade-mac-release-${{ inputs.release_tag }}-* + path: release-assets/mac + merge-multiple: true + + - name: Merge per-arch updater manifests into latest-mac.yml + run: | + set -euo pipefail + # Each per-arch build uploaded latest-mac-.yml; merge them into a + # single latest-mac.yml whose files[] lists both arch zips (electron-updater + # picks the right one by arch). --ignore-scripts skips the heavy electron + # postinstall — we only need the pure-JS `yaml` dep for the merge. + (cd apps/desktop && npm ci --ignore-scripts) + node apps/desktop/scripts/merge-mac-latest-yml.mjs \ + release-assets/mac/latest-mac-arm64.yml \ + release-assets/mac/latest-mac-x64.yml \ + release-assets/mac/latest-mac.yml + rm -f release-assets/mac/latest-mac-arm64.yml release-assets/mac/latest-mac-x64.yml + + - name: Download Windows release artifacts + if: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} + uses: actions/download-artifact@v4 + with: + name: ade-win-release-${{ inputs.release_tag }} + path: release-assets/win + + - name: Download ADE runtime binaries + uses: actions/download-artifact@v4 + with: + pattern: ade-runtime-* + path: release-assets/runtime + merge-multiple: true + + - name: Add standalone runtime installer + run: | + cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh + chmod 755 release-assets/runtime/install.sh + + # The published SHA256SUMS is generated later, over the exact merged set of + # standalone assets this release uploads. Hashing release-assets/runtime on + # its own would describe a set that is never published once the Windows + # gate contributes the signed win32 standalone files. + - name: Validate publish asset manifest + run: | + set -euo pipefail + shopt -s nullglob + + require_file() { + local file="$1" + local label="${2:-$1}" + if [ ! -s "$file" ]; then + echo "::error::Missing or empty $label: $file" + exit 1 + fi + } + + require_glob() { + local pattern="$1" + local label="${2:-$1}" + mapfile -t matches < <(compgen -G "$pattern" || true) + if [ "${#matches[@]}" -eq 0 ]; then + echo "::error::Missing $label matching $pattern" + exit 1 + fi + for file in "${matches[@]}"; do + require_file "$file" "$label" + done + } + + require_glob 'release-assets/mac/*.dmg' 'macOS DMG' + require_glob 'release-assets/mac/*.zip' 'macOS zip' + require_file 'release-assets/mac/latest-mac.yml' 'macOS auto-update metadata' + + require_file 'release-assets/runtime/install.sh' 'standalone runtime installer' + if [ ! -x 'release-assets/runtime/install.sh' ]; then + echo "::error::Standalone runtime installer is not executable." + exit 1 + fi + + # Cross-platform runtime allowlist. Every ade-* file in the runtime + # download must be one of the ten this release is allowed to carry, in + # every flag state, so an extra or missing sidecar can never reach the + # draft. All ten are produced by build-runtime-binaries in this run. + runtime_files=( + ade-darwin-arm64 + ade-darwin-arm64.native.tar.gz + ade-darwin-x64 + ade-darwin-x64.native.tar.gz + ade-linux-arm64 + ade-linux-arm64.native.tar.gz + ade-linux-x64 + ade-linux-x64.native.tar.gz + ade-win32-x64.exe + ade-win32-x64.native.tar.gz + ) + mapfile -t actual_runtime_files < <( + find release-assets/runtime -maxdepth 1 -type f -name 'ade-*' -printf '%f\n' | LC_ALL=C sort + ) + mapfile -t expected_runtime_files < <(printf '%s\n' "${runtime_files[@]}" | LC_ALL=C sort) + if ! diff -u <(printf '%s\n' "${expected_runtime_files[@]}") <(printf '%s\n' "${actual_runtime_files[@]}"); then + echo "::error::Runtime artifacts contain an unauthorized or missing entry." + exit 1 + fi + + for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do + binary="release-assets/runtime/ade-$target" + require_file "$binary" "ADE runtime binary for $target" + require_file "release-assets/runtime/ade-$target.native.tar.gz" "ADE native dependency archive for $target" + archive_listing="$RUNNER_TEMP/ade-$target-native-files.txt" + tar -tzf "release-assets/runtime/ade-$target.native.tar.gz" > "$archive_listing" + grep -q '^\./node_modules/' "$archive_listing" || { + echo "::error::ADE native dependency archive for $target is missing node_modules." + exit 1 + } + done + + - name: Validate gated Windows publish asset manifest + if: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} + run: | + set -euo pipefail + shopt -s nullglob + + # Desktop installer set, built and signed by build-win-release in this + # same run. + installers=(release-assets/win/ADE-*-win-x64.exe) + blockmaps=(release-assets/win/ADE-*-win-x64.exe.blockmap) + if [ "${#installers[@]}" -ne 1 ] || [ "${#blockmaps[@]}" -ne 1 ]; then + echo "::error::Expected exactly one signed Windows installer and blockmap." + exit 1 + fi + test -s "${installers[0]}" + test -s "${blockmaps[0]}" + test -s release-assets/win/latest.yml + + # Updater feed correctness: latest.yml must name the installer that is + # actually being published beside it, the same contract the merged + # latest-mac.yml carries for macOS. + installer_name="$(basename "${installers[0]}")" + if ! grep -Fq "$installer_name" release-assets/win/latest.yml; then + echo "::error::release-assets/win/latest.yml does not reference the published installer $installer_name." + exit 1 + fi + + # Standalone Windows runtime, built and signed by build-runtime-binaries + # in this same run. + test -s release-assets/runtime/install.ps1 + test -s release-assets/runtime/ade-win32-x64.exe + test -s release-assets/runtime/ade-win32-x64.native.tar.gz + # Cross-check the Windows standalone bytes against the digests computed + # on the signing runner, so a corrupted artifact transfer cannot reach + # the draft. That manifest is written by Git Bash on windows-latest, + # where sha256sum reads in binary mode and marks each name with a + # leading '*'; normalize it before parsing. Text and binary mode are + # identical here on Linux, so the normalized form verifies correctly. + test -s release-assets/runtime/SHA256SUMS + windows_sums="$RUNNER_TEMP/windows-standalone-SHA256SUMS" + sed 's/^\([0-9a-f]\{64\}\) [ *]/\1 /' release-assets/runtime/SHA256SUMS > "$windows_sums" + if grep -Ev '^[0-9a-f]{64} [A-Za-z0-9._-]+$' "$windows_sums"; then + echo "::error::Windows standalone checksum manifest has an invalid entry." + exit 1 + fi + mapfile -t actual_checksum_files < <(awk '{ print $2 }' "$windows_sums" | LC_ALL=C sort) + mapfile -t expected_checksum_files < <( + printf '%s\n' install.ps1 ade-win32-x64.exe ade-win32-x64.native.tar.gz | LC_ALL=C sort + ) + if ! diff -u <(printf '%s\n' "${expected_checksum_files[@]}") <(printf '%s\n' "${actual_checksum_files[@]}"); then + echo "::error::Windows standalone checksum manifest does not name the exact authorized Windows runtime set." + exit 1 + fi + (cd release-assets/runtime && sha256sum -c "$windows_sums") + + - name: Create or update draft GitHub release + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ inputs.release_tag }} + TARGET_REF: ${{ inputs.target_ref }} + GH_REPO: ${{ github.repository }} + PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} + run: | + set -euo pipefail + shopt -s nullglob + # Every asset comes from this run. The per-arch macOS zips + + # latest-mac.yml are what electron-updater consumes; DMGs are the human + # downloads. Mac blockmaps stay omitted. The Windows installer, + # blockmap and latest.yml are the electron-updater equivalents. + base_files=( + release-assets/mac/*.dmg + release-assets/mac/*.zip + release-assets/mac/latest-mac.yml + ) + # Cross-platform standalone runtime. These are notarized or signed by + # the build-runtime-binaries jobs of this same run, which is what keeps + # them byte-identical to the copies bundled inside the desktop + # installers published beside them. + runtime_files=( + release-assets/runtime/install.sh + release-assets/runtime/ade-darwin-* + release-assets/runtime/ade-linux-* + ) + files=("${base_files[@]}" "${runtime_files[@]}") + checksum_files=("${runtime_files[@]}") + + # One switch decides whether this release carries Windows at all. + if [ "$PUBLISH_WINDOWS" = "1" ]; then + windows_files=( + release-assets/win/ADE-*-win-x64.exe + release-assets/win/ADE-*-win-x64.exe.blockmap + release-assets/win/latest.yml + release-assets/runtime/install.ps1 + release-assets/runtime/ade-win32-x64.exe + release-assets/runtime/ade-win32-x64.native.tar.gz + ) + files=("${files[@]}" "${windows_files[@]}") + checksum_files+=( + release-assets/runtime/install.ps1 + release-assets/runtime/ade-win32-x64.exe + release-assets/runtime/ade-win32-x64.native.tar.gz + ) + fi + + # One checksum manifest over exactly the standalone assets uploaded + # below, regenerated so the digests always describe the bytes actually + # published rather than any per-job manifest staged upstream. + # install-runtime.sh and install-runtime.ps1 resolve their own + # platform's entries from this file. + mkdir -p release-assets/publish + published_checksums=release-assets/publish/SHA256SUMS + for checksum_file in "${checksum_files[@]}"; do + printf '%s %s\n' \ + "$(sha256sum "$checksum_file" | cut -d ' ' -f 1)" \ + "$(basename "$checksum_file")" + done | LC_ALL=C sort -k2 > "$published_checksums" + if grep -Ev '^[0-9a-f]{64} [A-Za-z0-9._-]+$' "$published_checksums"; then + echo "::error::Published checksum manifest has an invalid entry." + exit 1 + fi + mapfile -t expected_published_checksums < <( + for checksum_file in "${checksum_files[@]}"; do basename "$checksum_file"; done | LC_ALL=C sort + ) + mapfile -t actual_published_checksums < <(awk '{ print $2 }' "$published_checksums") + if ! diff -u \ + <(printf '%s\n' "${expected_published_checksums[@]}") \ + <(printf '%s\n' "${actual_published_checksums[@]}"); then + echo "::error::Published checksum manifest does not name the exact published standalone asset set." + exit 1 + fi + files+=("$published_checksums") + + if [ "${#files[@]}" -eq 0 ]; then + echo "::error::No release artifacts were found after validation." + exit 1 + fi + + if gh release view "$TAG_NAME" --repo "$GH_REPO" >/dev/null 2>&1; then + is_draft="$(gh release view "$TAG_NAME" --repo "$GH_REPO" --json isDraft --jq '.isDraft')" + if [ "$is_draft" != "true" ]; then + echo "::error::Release $TAG_NAME is already public. Refusing to overwrite published assets." + exit 1 + fi + approved_target="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + release_tag_target="$(gh api "repos/$GH_REPO/commits/$TAG_NAME" --jq '.sha' | tr '[:upper:]' '[:lower:]')" + if [ "$release_tag_target" != "$approved_target" ]; then + echo "::error::Existing draft tag $TAG_NAME resolves to $release_tag_target, not approved target $approved_target." + exit 1 + fi + mapfile -t existing_assets < <( + gh release view "$TAG_NAME" --repo "$GH_REPO" --json assets --jq '.assets[].name' + ) + for asset in "${existing_assets[@]}"; do + gh release delete-asset "$TAG_NAME" "$asset" --repo "$GH_REPO" --yes + done + gh release upload "$TAG_NAME" "${files[@]}" --repo "$GH_REPO" --clobber + else + gh release create "$TAG_NAME" "${files[@]}" \ + --repo "$GH_REPO" \ + --draft \ + --title "$TAG_NAME" \ + --generate-notes \ + --target "$TARGET_REF" + fi + + approved_target="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + final_tag_target="$(gh api "repos/$GH_REPO/commits/$TAG_NAME" --jq '.sha' | tr '[:upper:]' '[:lower:]')" + if [ "$final_tag_target" != "$approved_target" ]; then + echo "::error::Draft release tag $TAG_NAME resolves to $final_tag_target, not approved target $approved_target." + exit 1 + fi + mapfile -t expected_assets < <( + for file in "${files[@]}"; do basename "$file"; done | LC_ALL=C sort + ) + mapfile -t actual_assets < <( + gh release view "$TAG_NAME" --repo "$GH_REPO" --json assets --jq '.assets[].name' | LC_ALL=C sort + ) + if ! diff -u <(printf '%s\n' "${expected_assets[@]}") <(printf '%s\n' "${actual_assets[@]}"); then + echo "::error::Draft release asset inventory differs from the exact validated set." + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c34da7510..13612fc0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,5 +26,34 @@ jobs: with: release_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }} target_ref: ${{ github.event_name == 'workflow_dispatch' && inputs.target_sha || github.sha }} - publish: true + secrets: inherit + + # Publishing is the only part of the release that needs contents: write, and + # it lives in its own reusable workflow so release-core.yml can also be called + # by the read-only prepare-release.yml dry run. See release-publish.yml. + # + # This is the gate release-core.yml's publish-release job used to carry + # verbatim, rebuilt from the build results release-core.yml exposes as + # outputs. Windows is a first-class release platform: when its gate is on, a + # failed or skipped Windows build blocks the draft exactly as a failed macOS + # build does. always() is still needed so the job evaluates when + # build-win-release is legitimately skipped with the gate off. There is no + # publish input any more; only this workflow calls release-publish.yml, so a + # dry run cannot reach it at all. + publish-release: + if: >- + ${{ + always() + && needs.run-release.outputs.runtime_result == 'success' + && needs.run-release.outputs.mac_result == 'success' + && ( + vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1' + || needs.run-release.outputs.windows_result == 'success' + ) + }} + needs: run-release + uses: ./.github/workflows/release-publish.yml + with: + release_tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }} + target_ref: ${{ github.event_name == 'workflow_dispatch' && inputs.target_sha || github.sha }} secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 433384c9f..141983637 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ iOS signing gotchas (don't repeat these): Desktop release: -- Tag a commit on `main` with `vX.Y.Z` and push the tag. `.github/workflows/release.yml` triggers, runs the `release-core.yml` job, and publishes a draft GitHub Release with `.dmg`, `.zip`, blockmap, and `latest-mac.yml` assets. The workflow requires the tagged commit to be an ancestor of `origin/main`. +- Tag a commit on `main` with `vX.Y.Z` and push the tag. `.github/workflows/release.yml` triggers, runs the `release-core.yml` job, and publishes a draft GitHub Release. The workflow requires the tagged commit to be an ancestor of `origin/main`. Assets are the macOS `.dmg` and `.zip` plus `latest-mac.yml`, and the standalone runtime set (`install.sh`, `SHA256SUMS`, and the `ade-darwin-*`/`ade-linux-*` binaries with their `.native.tar.gz` archives). When the repository variable `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED` is `1`, Windows builds fresh on the same tag and adds `ADE--win-x64.exe`, its `.blockmap`, `latest.yml`, `install.ps1`, and `ade-win32-x64.exe` with its `.native.tar.gz`. That variable is the only Windows switch; with it off the Windows jobs skip cleanly and do not block the macOS release. - Draft releases stay unpublished until you flip them (`gh release edit vX.Y.Z --draft=false` or the UI). Don't publish silently. - Main is protected by a ruleset: admin bypass is required for direct pushes, and the "strict required status checks" rule makes GitHub's "Merge pull request" button reject merges that use a non-linear history (even when the branch already contains `main`). `gh pr merge --admin` hits the same block; merging locally and pushing (admin bypass) is the fallback. diff --git a/WINDOWS_PORT.md b/WINDOWS_PORT.md new file mode 100644 index 000000000..00aa04f1e --- /dev/null +++ b/WINDOWS_PORT.md @@ -0,0 +1,433 @@ +# Windows port evaluation + +## Executive summary + +ADE does not need a ground-up Windows port. Most platform foundations already +exist: Windows named pipes, PowerShell/cmd PTYs, Git for Windows resolution, +process-tree termination, Windows native provider packages, `node-pty`, a +vendored x64 `crsqlite.dll`, NSIS packaging, CLI wrappers, and extensive +artifact validation. + +The repository history confirms this. Windows foundations landed in April-May +2026 (`#186`, `#213`, and `#281`), and the release was deliberately made +macOS-only on June 12 in `#561`. However, simply uncommenting the Windows +workflow would not produce a dependable release. + +Recommended direction: + +- Target Windows 10/11 x64 using the existing per-user NSIS installer. +- Ship a bounded "Windows x64 preview" PR instead of promising complete + platform parity. +- Explicitly defer Windows ARM64, Windows as a remotely installable ADE brain, + native Windows computer use, and iOS Simulator support. + +The highest risk is the packaged background brain lifecycle, not Electron +rendering or TypeScript compilation. + +## Implementation status on `windows-native-build` + +The code changes recommended by this evaluation are now implemented on the +working branch: + +- The Windows brain runs through a per-user/channel current-user startup entry + and a BOM-marked PowerShell launcher that restores the complete resolved + runtime environment without requiring administrator access. Legacy Scheduled + Task cleanup fails closed, and runtime/desktop-bridge named pipes are isolated by canonical ADE + home, channel, and current user. Windows IPC servers explicitly retain + Node's intended-user-only named-pipe access flags; effective cross-account + access remains a clean-VM proof gate. +- Tracked CLI continuation uses structured command/argv/env descriptors on + Windows for Claude, Codex, Cursor, OpenCode, and Droid. App Control likewise + uses structured Windows launches for direct Electron/package scripts and + platform-specific shell fallbacks. Fresh provider intent is materialized on + the runtime that owns the lane, so a Windows renderer cannot send + PowerShell wrappers or Windows skill paths to a pinned macOS/Linux runtime. +- The Windows x64 package contains every supported Darwin/Linux remote-runtime + sidecar. Required `win-unpacked` package smoke validates the CLI/TUI, + ConPTY, bundled Claude/Codex/OpenCode binaries, Cursor native helpers, + Cursor/Droid SDK entry points, update authority, and a real `crsqlite.dll` + CRR mutation. The NSIS uninstaller stops and removes the Windows background + service, then removes only the terminal shim and user `PATH` entry owned by + that installation. Installing the generated NSIS package remains a separate + external gate. +- Required pull-request CI now builds an unsigned NSIS preview on + `windows-latest`. Production Windows build and public release are separately + gated; the signed path requires a pinned Authenticode identity, matching + signer for installer and `ADE.exe`, and a trusted RFC3161 timestamp. +- The updater authority follows the repository that built the package. The + source default remains upstream `arul28/ADE`, while CI passes + `ADE_RELEASE_REPOSITORY=${{ github.repository }}` for fork builds. Windows + download links and release assets remain disabled until the public gates + are explicitly enabled. +- Windows chrome, AppUserModelID, microphone-denial guidance, sync health, and + platform-aware copy/navigation are implemented. macOS-native Notch, + computer-use, and iOS Simulator actions are hidden or capability-blocked + while App Control, Browser, and proof ingestion remain available. +- The Windows developer loop now uses a per-user named pipe, invokes local + JavaScript CLI entry points instead of fragile global `.cmd` shims, strips + inherited runtime parent/idle shutdown controls, and waits for tsup's + explicit successful-build signal before starting or restarting Electron. + Runtime startup remains bounded at 30 seconds and reports an early child + exit immediately. The launcher records whether it created the detached + runtime and shuts down only that owned runtime when Electron exits or the + developer interrupts the command, so a failed or closed dev session does + not leave a polling runtime behind. +- Windows background probes and worker processes are created with hidden + console windows. This includes background-service status checks that the + desktop polls every two seconds, provider/auth/usage/Git/Tailscale probes, + runtime and PTY workers, and service install/uninstall operations. The Unix + `ps` resource sampler now reports `unsupported-platform` on Windows without + launching a process. These protections address a host-loss incident where + visible PowerShell console windows were repeatedly created and continued + after the Electron window closed. +- Windows sync-host startup now rejects stale lock files when the recorded PID + has been reused by a different executable and records process start time for + future locks. The projectless brain uses the same 8787-8999 fallback range + as project-scoped sync instead of waiting forever on 8787. This matters on + Windows hosts where Tailscale or another local service already owns 8787. + +No source blocker is currently known for the bounded Windows x64 desktop +preview. The remaining release work is external proof: clean standard-user +Windows 10/11 install/logoff/reboot/uninstall, +Stable+Beta and two-user isolation, physical-iPhone CRR/firewall testing, +provider/PTY special-character coverage, supported macOS/Linux remote +bootstrap, and signed-installer launch/relaunch/background-brain recovery. +Do not enable `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` or +`VITE_ADE_WINDOWS_DOWNLOAD_ENABLED=1` before those checks pass. + +Maintainers can follow +[`docs/playbooks/windows-signed-release.md`](docs/playbooks/windows-signed-release.md) +for signing, draft-release verification, publication, +and website-enable procedure. The exact-SHA manifest, redacted evidence layout, +and full-system acceptance inventory live in +[`docs/development/windows-release-proof.md`](docs/development/windows-release-proof.md); +installed-host diagnosis is in +[`docs/development/windows-support.md`](docs/development/windows-support.md). + +Windows standalone install and `ade brain update` are implemented. Windows is +still not an SSH-bootstrap target, and public readiness requires exact-SHA +evidence for the standalone executable, native archive, `install.ps1`, and +`SHA256SUMS` from the immutable proof run. Missing or mismatched evidence blocks +release; a manual binary copy is not acceptable proof. + +## Current readiness + +| Area | Current state | Required work | +| --- | --- | --- | +| Electron/NSIS packaging | Required unsigned PR package job, owned-integration uninstall cleanup, and signed release path are implemented | Clean-VM installer proof | +| Native dependencies | `win-unpacked` smoke loads ConPTY/provider payloads and performs a real `crsqlite.dll` CRR mutation | Repeat from an installed signed build | +| Projects, lanes, Git, files | Windows-aware paths, Git, and junction code exist | Clean-VM functional testing | +| Terminal/PTY | Structured Windows launch/resume, runtime-host materialization, and taskkill cleanup are implemented | Installed provider/ConPTY matrix | +| Background brain | No-admin per-user/channel startup launcher and NSIS uninstall cleanup are implemented | Logoff/reboot/update/uninstall proof | +| Updater | Fork authority and fail-closed signing/publication gates are implemented | Validate automatic updating after two signed Windows releases exist | +| Windows developer loop | Per-user runtime pipe, successful-build-gated Electron launch, hidden background probes, and owned-runtime cleanup are implemented and host-tested | Repeat from a clean clone | +| Sync/iPhone pairing | Intended to work | CRR roundtrip and firewall testing | +| Built-in browser/proof ingest | Mostly platform-neutral | Windows Hello, download, and security testing | +| Native computer use | macOS-only by design; capability-gated on Windows | Separate native Windows project | +| iOS Simulator/Xcode Preview | macOS-only and hidden on Windows | No Windows work required | +| Windows remote brain host | Explicitly rejected | Separate project | +| Windows ARM64 | Native payloads incomplete | Separate project | + +The repository's own +[Windows port document](docs/development/windows-port-lane.md#already-in-this-branch-do-not-re-implement) +accurately lists the foundations, but its release claims are stale. + +## Original release-blocking findings + +The sections below preserve the static-evaluation rationale that shaped the +implementation. Each release-blocking source finding below has an +implementation on this branch; effective named-pipe access, clean-VM +login-startup behavior, and signed-update behavior still require the +external proof gates above. + +### 1. The scheduled background brain drops required environment variables + +This is the most serious defect. + +The service command carries `ELECTRON_RUN_AS_NODE=1`, `NODE_PATH`, channel, ADE +home, and runtime configuration in +[`common.ts`](apps/ade-cli/src/serviceManager/common.ts). However, +`renderWindowsCommand()` serializes only the executable and arguments. + +The resulting task registers roughly: + +```text +ADE.exe cli.cjs serve +``` + +without `ELECTRON_RUN_AS_NODE=1`. On a clean machine this can reopen the +Electron GUI instead of starting the CLI brain. Because ADE expects the service +to own the primary runtime pipe, this can leave the desktop without its normal +synchronized runtime. + +The PR should install a dedicated service launcher or safely serialize all +required environment variables, then prove install, start, logoff/logon, +update, and uninstall on a clean machine without Node installed. + +### 2. Background-service registration must not require administrator access + +Windows Task Scheduler rejects task creation from a standard user. ADE's +per-user installer must not require elevation merely to start its background +runtime at login. + +The implementation now writes a channel- and user-qualified value under the +current user's normal Windows startup registry key. A hidden PowerShell +supervisor starts the packaged runtime, records its process identity, and lets +status and uninstall stop only that ADE-owned process tree. Old Scheduled Task +registrations are removed during migration. + +### 3. The Windows package cannot satisfy its own validator + +The Windows validator requires Darwin and Linux x64/arm64 remote-runtime +sidecars in +[`validate-win-artifacts.mjs`](apps/desktop/scripts/validate-win-artifacts.mjs), +but [`package.json`](apps/desktop/package.json) copies only the Darwin +artifacts. + +A re-enabled `dist:win` should therefore fail post-package validation. + +The product decision is either: + +- Include all four sidecar pairs so Windows can bootstrap existing macOS/Linux + remote runtimes; or +- Reduce the Windows remote-bootstrap contract, gate the feature, and update + the validator accordingly. + +Including everything is simpler for a first preview but increases installer +size. On-demand, checksummed sidecar downloads would be cleaner later. + +### 4. Provider resume and App Control commands still contain POSIX syntax + +Fresh provider launches are mostly structured and Windows-aware. Resume and +fallback paths frequently generate shell strings instead. + +Examples include OpenCode environment assignments and Droid resume commands in +[`cliLaunch.ts`](apps/desktop/src/shared/cliLaunch.ts). App Control's +package-script rewrite emits `PATH=:$PATH` and POSIX quoting in +[`appControlLaunchCommand.ts`](apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts), +even though the command is later typed into PowerShell or cmd. + +These paths will fail with some configurations and paths containing spaces, +quotes, `$`, `%`, `&`, or backticks. + +The durable fix is a structured invocation contract: + +```ts +{ + command, + args, + env, + displayCommand, +} +``` + +Shell text should remain only for commands that genuinely require a shell, +with separate PowerShell and cmd quoting. + +### 5. Windows named-pipe identity is not sufficiently isolated + +The machine pipe name is derived only from the basename of ADE home in +[`machineLayout.ts`](apps/ade-cli/src/services/projects/machineLayout.ts). The +default `.ade` therefore produces the same global named-pipe name for every +Windows user. + +The PR should derive pipe names from the canonical ADE home, channel, and +current user SID/hash, and verify that the pipe ACL is limited to the intended +user. Test two Windows users and Stable/Beta side by side. + +### 6. Release and update configuration is disabled or points at upstream + +The Windows build, download, validation, and upload blocks are commented out +in [`.github/workflows/release-core.yml`](.github/workflows/release-core.yml). +There is also no Windows runner in normal PR CI. + +At evaluation time, the packaged updater was hardcoded to upstream +`arul28/ADE` in: + +- [`apps/desktop/package.json`](apps/desktop/package.json) +- [`apps/desktop/resources/app-update.yml`](apps/desktop/resources/app-update.yml) +- [`autoUpdateService.ts`](apps/desktop/src/main/services/updates/autoUpdateService.ts) + +A Windows build published by another fork would check upstream for updates, +where the corresponding Windows artifacts might not exist. + +The distribution repository should be build metadata generated from +`github.repository`. The production `setFeedURL` override should be removed or +centralized. Electron-builder recommends using its generated `app-update.yml`; +its NSIS target already supports Windows auto-update and `latest.yml` +metadata. See the +[electron-builder auto-update documentation](https://www.electron.build/docs/features/auto-update/). + +### 7. Public signing currently fails open + +The existing configuration supports Authenticode, but missing secrets result +in an unsigned installer. The validator only checks signatures when an opt-in +flag is set. + +Recommended policy: + +- PR CI may build an unsigned artifact. +- Release CI must fail if signing is unavailable. +- Verify the installer and installed `ADE.exe`, publisher identity, and RFC + 3161 timestamp. +- Publish the installer, blockmap, and `latest.yml` atomically. +- Use Microsoft Artifact Signing or a stable organizational Authenticode + certificate. + +Electron-builder exposes `forceCodeSigning` specifically to prevent silently +unsigned production builds. See the +[electron-builder signing documentation](https://www.electron.build/docs/features/code-signing/). + +Signing will not automatically eliminate every early SmartScreen prompt. +Microsoft notes that even valid OV/EV-signed applications can be classified as +unrecognized until publisher/file reputation develops; unsigned releases must +rebuild reputation for every version. See +[Microsoft's SmartScreen guidance](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/smartscreen-reputation). + +## Product and UX work + +The build PR should also include a focused platform pass: + +- Make the Windows title bar explicit. + [`main.ts`](apps/desktop/src/main/main.ts) unconditionally uses + `hiddenInset`, macOS traffic-light positioning, and a renderer header with + 80 px of left padding. Verify caption buttons, dragging, double-click + maximize, Snap Layouts, and DPI scaling. +- Hide or clearly disable iOS Simulator, Xcode Preview, native Notch, and local + OS computer-use actions. +- Keep browser/App Control capture and proof-file ingestion enabled where + supported. +- Replace visible "This Mac", "Reveal in Finder", `Command` key, and macOS + Keychain wording with platform-aware labels. Preserve the internal + `this-mac` identifier because it is a protocol/persistence invariant. +- Update the website. + [`DownloadPage.tsx`](apps/web/src/app/pages/DownloadPage.tsx) currently says + Windows installers are not published. +- Add Windows-specific microphone denial guidance; the current flow treats + non-macOS access as automatically granted. +- Add a Windows sync-health surface. A missing or unloadable `crsqlite.dll` + currently degrades sync primarily through logs. +- Test Windows Defender Firewall behavior for LAN phone pairing and provide + actionable relay/Tailscale guidance. +- Add `setAppUserModelId` if packaged toast identity proves unreliable. + +The supported OS floor should be Windows 10/11 x64. ADE uses Electron 41, +while Electron 23 and newer require Windows 10 or later. See +[Electron platform support](https://www.electronjs.org/docs/latest/breaking-changes). + +## Recommended PR boundary + +A reviewable first submission should be titled along the lines of +"Add Windows x64 preview build" and contain the following work. + +### 1. Runtime correctness + +- Fix the scheduled-task environment, channel naming, and locale-safe status. +- Use user/channel-scoped named pipes. +- Introduce structured provider resume commands. +- Fix App Control's Windows launch handling. +- Generate sync singleton recovery commands that do not suggest `launchctl` + or `/bin/kill`. + +### 2. Packaging and CI + +- Resolve the remote-sidecar mismatch. +- Add `windows-latest` PR packaging and smoke validation. +- Load `crsqlite.dll` and perform a minimal CRR operation during packaged + smoke. +- Probe the bundled CLI/TUI, PTY, and provider executables. +- Keep the target x64-only. + +### 3. Release and updates + +- Parameterize the fork's release authority. +- Restore the release/publish workflow. +- Fail closed on production signing. +- Validate installed N-to-N+1 signed updating after two releases exist. + +### 4. Platform UX and documentation + +- Add an explicit Windows title bar and capability-driven navigation. +- Use neutral copy and platform-aware shortcuts. +- Add Windows download and analytics links. +- Correct stale architecture and Windows-port documentation. + +Public download enablement should remain gated until the signed installer +passes the clean-host checks. If certificate provisioning is not ready, the PR +can still produce an unsigned internal CI artifact while leaving public +publishing disabled. + +## Merge gates + +At minimum: + +- Test Windows 10 22H2 and Windows 11 x64 clean standard-user VMs. +- Install without Node or administrator rights. +- Verify first launch, app restart, logoff/logon, and uninstall/reinstall. +- Install Stable and Beta simultaneously. +- Open/create a project; create/delete a lane; exercise worktree, junction, + commit, rebase, and conflict flows. +- Exercise PowerShell and cmd PTYs: Unicode, resize, Ctrl+C, cancellation, and + child-tree cleanup. +- Test fresh launch and resume for Claude, Codex, Cursor, Droid, and OpenCode. +- Test paths and prompts containing spaces, Unicode, quotes, `$`, `%`, and + `&`. +- Load packaged `crsqlite.dll` and complete a bidirectional Windows + desktop-to-physical-iPhone CRR sync. +- Use the Windows desktop to control an existing macOS/Linux remote runtime. +- Exercise the built-in browser, downloads, proof ingest, and App Control CDP + capture. +- Test `ade://` cold/hot deep links, file associations, the PATH wrapper, and + uninstall cleanup. +- Test DPI at 100/125/150/200 percent, multiple monitors, Snap Layouts, high + contrast, and keyboard navigation. + +## Explicit follow-ups + +These should not block the first Windows desktop build: + +- Windows as a remotely installable ADE brain. +- Native Windows computer use using Windows Graphics Capture/UI Automation. +- Signed N-to-N+1 automatic-update testing, including cache/retry/relaunch, + HKCU startup-supervisor recovery, legacy Scheduled Task cleanup, data + preservation, and rejection of tampered or incorrectly signed updates. +- Windows ARM64 after all native/provider payloads are available. +- Windows resource telemetry and general orphan-agent recovery. + +## Readiness and uncertainty + +- The source and CI support a credible internal Windows x64 preview. +- Public Windows x64 readiness requires upstream signing configuration and the + external proof gates below. +- Largest uncertainty: installed, signed runtime behavior across clean Windows + hosts and updates. + +The initial assessment was a read-only static evaluation. Implementation and +targeted automated validation have since been completed on this branch. +A full local NSIS package still requires the CI-produced Darwin/Linux runtime +sidecars; the required Windows CI job materializes them before packaging. +No claim is made here that the external clean-VM checks or automatic-update +follow-up have passed. + +## Automated validation observed + +The source implementation was validated on Windows with: + +- A bounded no-GUI lifecycle proof that started an isolated hidden runtime + while this host's Tailscale service owned port 8787, connected over its + named pipe, requested graceful shutdown, and confirmed that the pipe was + released. +- Desktop typecheck, lint, build, documentation validation, web typecheck and + build. +- The required Windows release contract, updater, packaging-smoke, + CR-SQLite, ConPTY, App Control, microphone, window-chrome, preload, sync UI, + provider-launch, and platform-copy focused suites. +- ADE CLI typecheck/build, 328 CLI tests, 59 service-manager tests, and 1,045 + TUI tests. +- `git diff --check`. + +The legacy full test suites still contain Windows-host baseline failures in +POSIX-only fixtures, Unix-socket browser tests, chmod assertions, and several +SQLite teardown races. Focused Windows production-path tests are green, but +those baseline failures should be cleaned up in follow-up work so the entire +local suite is signal-bearing on Windows. diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 67ee6c249..514cf243e 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -46,12 +46,18 @@ Source dev launches use the temp dev endpoint and `ade-desktop-dev` Electron pro Three ways to put `ade` on a machine: -1. **Standalone runtime install** — single static binary plus its native dependency archive, fetched from a GitHub release. Suitable for headless macOS/Linux servers. +1. **Standalone runtime install** — single static binary plus its native dependency archive, fetched from a GitHub release. Suitable for headless macOS/Linux servers and Windows x64 machines. ```bash curl -fsSL https://github.com/arul28/ADE/releases/latest/download/install.sh | sh ``` + Windows PowerShell: + + ```powershell + irm https://github.com/arul28/ADE/releases/latest/download/install.ps1 | iex + ``` + Environment overrides accepted by `install.sh`: - `ADE_VERSION=vX.Y.Z` — install a specific release tag (default `latest`). @@ -59,7 +65,9 @@ Three ways to put `ade` on a machine: - `ADE_RELEASE_REPO=owner/repo` — fetch from a fork. - `ADE_HOME=/custom/.ade` — change the per-machine state root. - The script downloads `ade-` to `$ADE_INSTALL_DIR/ade`, verifies it and `ade-.native.tar.gz` against `SHA256SUMS`, extracts the archive to `$ADE_HOME/runtime//`, runs `ade --version` to verify, and best-effort registers the per-user login service on macOS / systemd. + For an unpublished Windows proof bundle, run `install.ps1 -AssetDirectory ` (or set `ADE_RELEASE_ASSET_DIR`) to install the local checksum, executable, and native archive without creating a GitHub Release. + + The POSIX script downloads `ade-` to `$ADE_INSTALL_DIR/ade`; the PowerShell script downloads `ade-win32-x64.exe` to `$ADE_INSTALL_DIR\ade.exe`. Both verify the binary and matching `.native.tar.gz` against `SHA256SUMS`, extract native dependencies under `$ADE_HOME/runtime//`, run `ade --version`, and register the per-user login service. The PowerShell installer also adds the install directory to the current user's `PATH` unless `-NoPath` is passed; use `-NoService` to skip startup registration. 2. **Desktop bundle** — every packaged ADE.app ships the CLI. macOS path: @@ -95,7 +103,7 @@ The ADE brain runs as a per-user login service. The implementations live in `src | Linux | `systemctl --user` | `~/.config/systemd/user/.service` | | Windows | HKCU `Run` entry + PowerShell supervisor | `HKCU\...\CurrentVersion\Run` value `ADE Runtime (-)` | -The default service label is `com.ade.runtime`; channel builds override it via `ADE_PACKAGE_CHANNEL=alpha|beta` (`com.ade.runtime.alpha`, `com.ade.runtime.beta`). `ADE_RUNTIME_SERVICE_NAME` overrides the label outright and is used for both launchd and systemd unit names. macOS writes `launchd.{out,err}.log` under `ADE_HOME/runtime/`. +The default service label is `com.ade.runtime`; channel builds override it via `ADE_PACKAGE_CHANNEL=alpha|beta` (`com.ade.runtime.alpha`, `com.ade.runtime.beta`). `ADE_RUNTIME_SERVICE_NAME` overrides the label outright. On Windows the label and current-user identity produce a channel/user-qualified Run-value name, launcher, advisory supervisor/runtime PID record, and named pipe. The Run value starts a hidden PowerShell supervisor; a successful initialized IPC response is the separate readiness record. Scheduled Tasks are legacy state that install/uninstall clean up, never the current service registration. macOS writes `launchd.{out,err}.log` under `ADE_HOME/runtime/`. ### Windows: how the always-on guarantee is actually obtained @@ -164,7 +172,7 @@ ade brain pin set 123456 ade brain pin clear ``` -The service manager builds the launch command from the current `ade` binary path so the installed service launches the same ADE channel that ran the install. Release installs use `$ADE_HOME/bin/ade`, which lets `ade brain update` stage the next release under `$ADE_HOME/runtime/updates/`, verify downloaded assets against `SHA256SUMS`, atomically promote the binary/native deps, and restart the login service without the desktop app being open. After a packaged desktop update, ADE also refreshes this service so the brain re-execs the updated bundled CLI instead of leaving clients attached to an older build hash. +The service manager builds the launch command from the current `ade` binary path so the installed service launches the same ADE channel that ran the install. Release installs use `$ADE_HOME/bin/ade` (`ade.exe` on Windows), which lets `ade brain update` stage the next release under `$ADE_HOME/runtime/updates/`, verify downloaded assets against `SHA256SUMS`, atomically promote the binary/native deps, and restart the login service without the desktop app being open. On Windows, update stops the existing process before replacing the executable, restores the previous executable, native tree, and service if promotion fails, and delegates staging cleanup until the running helper exits so Windows file locks do not retain update payloads. Failed compensation reports and preserves the exact recovery files instead of silently discarding the rollback error. After a packaged desktop update, ADE also refreshes this service so the brain re-execs the updated bundled CLI instead of leaving clients attached to an older build hash. ## Internal process command diff --git a/apps/ade-cli/scripts/build-static.mjs b/apps/ade-cli/scripts/build-static.mjs index 7d496699c..01971468d 100644 --- a/apps/ade-cli/scripts/build-static.mjs +++ b/apps/ade-cli/scripts/build-static.mjs @@ -59,8 +59,8 @@ function currentTarget() { } function validateTarget(target) { - if (!/^(darwin|linux)-(arm64|x64)$/.test(target)) { - throw new Error(`Unsupported runtime target '${target}'. Expected darwin-arm64, darwin-x64, linux-arm64, or linux-x64.`); + if (!/^(?:(?:darwin|linux)-(?:arm64|x64)|win32-x64)$/.test(target)) { + throw new Error(`Unsupported runtime target '${target}'. Expected darwin-arm64, darwin-x64, linux-arm64, linux-x64, or win32-x64.`); } } @@ -77,6 +77,7 @@ async function run(command, args, options = {}) { cwd: packageRoot, env: process.env, maxBuffer: 50 * 1024 * 1024, + windowsHide: process.platform === "win32", ...options, }); stdout = result.stdout; @@ -122,12 +123,91 @@ async function assertSeaCapableNodeBinary(binaryPath) { ].join(" ")); } -async function removeSignatureIfNeeded(binaryPath) { - if (process.platform !== "darwin") return; +/** + * Locate signtool.exe. It ships with the Windows SDK and is normally absent + * from PATH, so fall back to scanning the SDK's versioned bin directories and + * take the newest. Returns null when no SDK is installed. + */ +async function resolveSignTool() { try { - await run("codesign", ["--remove-signature", binaryPath]); + await run("signtool", ["/?"]); + return "signtool"; } catch { - // Some Node builds are unsigned. postject can proceed in that case. + // Not on PATH; fall through to the SDK layout. + } + const roots = [process.env["ProgramFiles(x86)"], process.env.ProgramFiles] + .filter(Boolean) + .map((base) => path.join(base, "Windows Kits", "10", "bin")); + for (const root of roots) { + let versions = []; + try { + versions = (await fs.readdir(root, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + .reverse(); + } catch { + continue; + } + for (const version of versions) { + for (const arch of ["x64", "x86"]) { + const candidate = path.join(root, version, arch, "signtool.exe"); + try { + await fs.access(candidate); + return candidate; + } catch { + // Try the next SDK layout. + } + } + } + } + return null; +} + +/** + * Strip the vendor signature before postject injects the SEA blob. + * + * Official Node.js releases are signed on BOTH macOS and Windows. postject + * rewrites the executable, so a signature left in place ends up covering bytes + * that no longer exist -- and the platform's signing tool then refuses to + * re-sign the result. On Windows that surfaces as + * + * SignTool Error: SignedCode::Sign returned error: 0x800700C1 + * + * which is ERROR_BAD_EXE_FORMAT, i.e. "this is not a valid PE". Node's SEA + * documentation requires removing the signature first on both platforms; only + * the darwin half was ever implemented here, so every signed Windows runtime + * build failed at the signing step. + */ +async function removeSignatureIfNeeded(binaryPath) { + if (process.platform === "darwin") { + try { + await run("codesign", ["--remove-signature", binaryPath]); + } catch { + // Some Node builds are unsigned. postject can proceed in that case. + } + return; + } + + if (process.platform === "win32") { + const signTool = await resolveSignTool(); + if (!signTool) { + // Only a signed release needs this; an unsigned local build is fine + // without it, and failing here would break `build:static` on a dev box + // that has no Windows SDK. + console.warn( + "[build-static] signtool.exe not found; skipping signature removal. " + + "A signed release build requires the Windows SDK, or signing will " + + "fail with 0x800700C1.", + ); + return; + } + try { + await run(signTool, ["remove", "/s", binaryPath]); + } catch { + // `remove /s` exits non-zero when the binary carries no signature, which + // is the desired end state, so treat that as success. + } } } @@ -308,7 +388,13 @@ async function main() { process.env.ADE_CLI_VERSION = runtimeVersion; if (!args.skipBuild) { - await run(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build"]); + if (process.platform === "win32") { + const npmCli = process.env.npm_execpath + || path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"); + await run(process.execPath, [npmCli, "run", "build"]); + } else { + await run("npm", ["run", "build"]); + } } const workDir = path.join(args.outDir, ".sea", args.target); @@ -330,7 +416,7 @@ async function main() { await fs.writeFile(seaConfigPath, `${JSON.stringify(seaConfig, null, 2)}\n`, "utf8"); await run(sourceNodeBinary, ["--experimental-sea-config", seaConfigPath]); - const binaryName = `ade-${args.target}${process.platform === "win32" ? ".exe" : ""}`; + const binaryName = `ade-${args.target}${args.target.startsWith("win32-") ? ".exe" : ""}`; const binaryPath = path.join(args.outDir, binaryName); await fs.copyFile(sourceNodeBinary, binaryPath); await fs.chmod(binaryPath, 0o755); @@ -346,7 +432,11 @@ async function main() { if (args.target.startsWith("darwin-")) { postjectArgs.push("--macho-segment-name", "NODE_SEA"); } - await run(path.join(packageRoot, "node_modules", ".bin", process.platform === "win32" ? "postject.cmd" : "postject"), postjectArgs); + if (process.platform === "win32") { + await run(process.execPath, [path.join(packageRoot, "node_modules", "postject", "dist", "cli.js"), ...postjectArgs]); + } else { + await run(path.join(packageRoot, "node_modules", ".bin", "postject"), postjectArgs); + } await adHocSignIfNeeded(binaryPath); let nativeArchivePath = null; diff --git a/apps/ade-cli/scripts/install-runtime.ps1 b/apps/ade-cli/scripts/install-runtime.ps1 new file mode 100644 index 000000000..74c6d75ca --- /dev/null +++ b/apps/ade-cli/scripts/install-runtime.ps1 @@ -0,0 +1,308 @@ +[CmdletBinding()] +param( + [string]$Version = $(if ($env:ADE_VERSION) { $env:ADE_VERSION } else { "latest" }), + [string]$Repo = $(if ($env:ADE_RELEASE_REPO) { $env:ADE_RELEASE_REPO } else { "arul28/ADE" }), + [string]$AssetDirectory = $env:ADE_RELEASE_ASSET_DIR, + [string]$InstallDir = $env:ADE_INSTALL_DIR, + [string]$AdeHome = $env:ADE_HOME, + [switch]$NoService, + [switch]$NoPath +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Fail([string]$Message) { + throw "ade install: $Message" +} + +function Resolve-AssetUrl([string]$Name) { + if ($Version -eq "latest") { + return "https://github.com/$Repo/releases/latest/download/$Name" + } + return "https://github.com/$Repo/releases/download/$Version/$Name" +} + +function Download-Asset([string]$Name, [string]$Destination) { + if (-not [string]::IsNullOrWhiteSpace($AssetDirectory)) { + $source = Join-Path ([IO.Path]::GetFullPath($AssetDirectory)) $Name + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + Fail "missing local runtime asset: $Name" + } + Copy-Item -LiteralPath $source -Destination $Destination -Force + return + } + $url = Resolve-AssetUrl $Name + if (-not $url.StartsWith("https://", [StringComparison]::OrdinalIgnoreCase)) { + Fail "refusing non-HTTPS runtime asset URL: $url" + } + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $Destination +} + +function Read-Checksum([string]$ManifestPath, [string]$AssetName) { + $foundChecksums = @() + foreach ($line in Get-Content -LiteralPath $ManifestPath -ErrorAction Stop) { + if ($line -match '^([a-fA-F0-9]{64})\s+\*?(.+)$') { + $candidate = [IO.Path]::GetFileName($Matches[2].Trim()) + if ([string]::Equals($candidate, $AssetName, [StringComparison]::Ordinal)) { + $foundChecksums += $Matches[1].ToLowerInvariant() + } + } + } + if ($foundChecksums.Count -ne 1) { + Fail "checksum manifest must contain exactly one entry for $AssetName" + } + return $foundChecksums[0] +} + +function Verify-Checksum([string]$ManifestPath, [string]$AssetName, [string]$FilePath) { + $expected = Read-Checksum $ManifestPath $AssetName + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $FilePath).Hash.ToLowerInvariant() + if (-not [string]::Equals($expected, $actual, [StringComparison]::Ordinal)) { + Fail "checksum mismatch for $AssetName" + } +} + +function Get-ShortSha256([string]$Value) { + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Value)))).Replace("-", "").Substring(0, 12).ToLowerInvariant() + } finally { + $sha.Dispose() + } +} + +function Stop-ServiceProcessPreservingRegistration([string]$HomePath) { + $launcherPath = Join-Path $HomePath "runtime\brain-service-$(Get-ShortSha256 'com.ade.runtime').ps1" + foreach ($process in @(Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { + $_.Name -match '^powershell(?:\.exe)?$' -and + ([string]$_.CommandLine).IndexOf($launcherPath, [StringComparison]::OrdinalIgnoreCase) -ge 0 + })) { + & taskkill.exe /PID ([string]$process.ProcessId) /T /F | Out-Null + if ($LASTEXITCODE -ne 0) { + Fail "could not restore the previous stopped ADE brain state" + } + } +} + +function Set-ProcessRuntimeEnvironment([string]$HomePath, [string]$RuntimePath) { + $env:ADE_HOME = $HomePath + $env:ADE_PACKAGE_CHANNEL = "stable" + $env:ADE_RUNTIME_ROOT = $RuntimePath + $env:ADE_RUNTIME_NODE_MODULES = Join-Path $RuntimePath "node_modules" + $env:NODE_PATH = if ([string]::IsNullOrWhiteSpace($script:PreviousNodePath)) { + $env:ADE_RUNTIME_NODE_MODULES + } else { + "$($env:ADE_RUNTIME_NODE_MODULES)$([IO.Path]::PathSeparator)$script:PreviousNodePath" + } +} + +function Remove-TrailingDirectorySeparators([string]$Value) { + $root = [IO.Path]::GetPathRoot($Value) + $minimumLength = if ($null -eq $root) { 0 } else { $root.Length } + while ($Value.Length -gt $minimumLength -and ($Value.EndsWith("\") -or $Value.EndsWith("/"))) { + $Value = $Value.Substring(0, $Value.Length - 1) + } + return $Value +} + +function Install-UserPath([string]$Directory) { + $normalized = Remove-TrailingDirectorySeparators ([IO.Path]::GetFullPath($Directory)) + $current = [Environment]::GetEnvironmentVariable("Path", "User") + $entries = if ([string]::IsNullOrWhiteSpace($current)) { @() } else { + @($current -split ";" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + foreach ($entry in $entries) { + try { + if ([string]::Equals( + (Remove-TrailingDirectorySeparators ([IO.Path]::GetFullPath($entry))), + $normalized, + [StringComparison]::OrdinalIgnoreCase + )) { return } + } catch {} + } + $next = if ($entries.Count -eq 0) { $normalized } else { "$normalized;$current" } + [Environment]::SetEnvironmentVariable("Path", $next, "User") + try { + if (-not ("Ade.RuntimeInstaller.EnvironmentBroadcast" -as [type])) { + Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +namespace Ade.RuntimeInstaller { + public static class EnvironmentBroadcast { + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + public static extern IntPtr SendMessageTimeout( + IntPtr hWnd, uint message, UIntPtr wParam, string lParam, + uint flags, uint timeout, out UIntPtr result); + } +} +"@ | Out-Null + } + $result = [UIntPtr]::Zero + [void][Ade.RuntimeInstaller.EnvironmentBroadcast]::SendMessageTimeout( + [IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, "Environment", 2, 5000, [ref]$result) + } catch { + Write-Warning "ADE was added to PATH, but running shells may need to be restarted." + } +} + +if ($env:PROCESSOR_ARCHITECTURE -notmatch '^(AMD64|x86_64)$' -and + $env:PROCESSOR_ARCHITEW6432 -notmatch '^(AMD64|x86_64)$') { + Fail "Windows x64 is required" +} +if ($Repo -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$') { + Fail "ADE_RELEASE_REPO must be in owner/repo form" +} +if ($Version -ne "latest" -and $Version -notmatch '^v?[A-Za-z0-9_.-]+$') { + Fail "ADE_VERSION must be latest or a release tag such as v1.2.13" +} +if (-not (Get-Command tar.exe -ErrorAction SilentlyContinue)) { + Fail "tar.exe is required; install current Windows updates and retry" +} + +if ([string]::IsNullOrWhiteSpace($AdeHome)) { + $AdeHome = Join-Path ([Environment]::GetFolderPath("UserProfile")) ".ade" +} +$AdeHome = [IO.Path]::GetFullPath($AdeHome) +if ([string]::IsNullOrWhiteSpace($InstallDir)) { + $InstallDir = Join-Path $AdeHome "bin" +} +$InstallDir = [IO.Path]::GetFullPath($InstallDir) + +$target = "win32-x64" +$binaryAsset = "ade-$target.exe" +$nativeAsset = "ade-$target.native.tar.gz" +$runtimeDir = Join-Path $AdeHome "runtime\$target" +$destinationBinary = Join-Path $InstallDir "ade.exe" +$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("ade-install-" + [Guid]::NewGuid().ToString("N")) +$stagedBinary = Join-Path $tempRoot "ade.exe" +$stagedArchive = Join-Path $tempRoot $nativeAsset +$checksumManifest = Join-Path $tempRoot "SHA256SUMS" +$stagedRuntime = Join-Path $tempRoot "runtime" +$backupBinary = Join-Path $tempRoot "ade.previous.exe" +$backupRuntime = Join-Path $tempRoot "runtime.previous" +$script:PreviousNodePath = $env:NODE_PATH +$previousEnvironment = @{ + ADE_HOME = $env:ADE_HOME + ADE_PACKAGE_CHANNEL = $env:ADE_PACKAGE_CHANNEL + ADE_RUNTIME_ROOT = $env:ADE_RUNTIME_ROOT + ADE_RUNTIME_NODE_MODULES = $env:ADE_RUNTIME_NODE_MODULES + NODE_PATH = $env:NODE_PATH +} +$previousServiceWasStopped = $false +$previousServiceWasRunning = $false +$promotedBinary = $false +$promotedRuntime = $false +$preserveTempForRecovery = $false + +try { + New-Item -ItemType Directory -Force -Path $tempRoot, $stagedRuntime | Out-Null + Download-Asset $binaryAsset $stagedBinary + Download-Asset $nativeAsset $stagedArchive + Download-Asset "SHA256SUMS" $checksumManifest + Verify-Checksum $checksumManifest $binaryAsset $stagedBinary + Verify-Checksum $checksumManifest $nativeAsset $stagedArchive + + & tar.exe -xzf $stagedArchive -C $stagedRuntime + if ($LASTEXITCODE -ne 0) { Fail "failed to extract $nativeAsset" } + if (-not (Test-Path -LiteralPath (Join-Path $stagedRuntime "node_modules") -PathType Container)) { + Fail "native dependency archive is missing node_modules" + } + + Set-ProcessRuntimeEnvironment $AdeHome $stagedRuntime + & $stagedBinary --version | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "downloaded ADE runtime failed its version check" } + + if ((Test-Path -LiteralPath $destinationBinary -PathType Leaf) -and -not $NoService) { + Set-ProcessRuntimeEnvironment $AdeHome $runtimeDir + $serviceStatusJson = (& $destinationBinary serve --service-status --json 2>$null | Out-String) + if ($LASTEXITCODE -ne 0) { Fail "existing ADE brain service state could not be read before update" } + try { + $serviceStatus = $serviceStatusJson | ConvertFrom-Json -ErrorAction Stop + } catch { + Fail "existing ADE brain service returned invalid status before update" + } + if ($serviceStatus.installed -isnot [bool]) { + Fail "existing ADE brain service returned incomplete status before update" + } + if ($serviceStatus.running -isnot [bool]) { + Fail "existing ADE brain service returned incomplete running state before update" + } + if ($serviceStatus.installed) { + $previousServiceWasRunning = $serviceStatus.running + & $destinationBinary serve --uninstall-service | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "existing ADE brain service could not be stopped for update" } + $previousServiceWasStopped = $true + } + } + + New-Item -ItemType Directory -Force -Path $InstallDir, (Split-Path $runtimeDir -Parent) | Out-Null + if (Test-Path -LiteralPath $destinationBinary -PathType Leaf) { + Move-Item -LiteralPath $destinationBinary -Destination $backupBinary + } + if (Test-Path -LiteralPath $runtimeDir) { + Move-Item -LiteralPath $runtimeDir -Destination $backupRuntime + } + Move-Item -LiteralPath $stagedRuntime -Destination $runtimeDir + $promotedRuntime = $true + Move-Item -LiteralPath $stagedBinary -Destination $destinationBinary + $promotedBinary = $true + + Set-ProcessRuntimeEnvironment $AdeHome $runtimeDir + & $destinationBinary --version | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "installed ADE runtime failed its version check" } + if (-not $NoService) { + & $destinationBinary serve --install-service | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "ADE installed, but its per-user brain service could not be registered" } + } + if (-not $NoPath) { Install-UserPath $InstallDir } + + Write-Output "ADE runtime installed: $destinationBinary" + Write-Output "ADE native runtime: $runtimeDir" + if (-not $NoPath) { Write-Output "Open a new terminal and run: ade doctor --text" } +} catch { + $installError = $_ + $rollbackErrors = [Collections.Generic.List[string]]::new() + if ($promotedBinary -and -not $NoService -and (Test-Path -LiteralPath $destinationBinary -PathType Leaf)) { + try { + Set-ProcessRuntimeEnvironment $AdeHome $runtimeDir + & $destinationBinary serve --uninstall-service 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { $rollbackErrors.Add("new brain service cleanup exited with code $LASTEXITCODE") } + } catch { $rollbackErrors.Add("new brain service cleanup failed: $($_.Exception.Message)") } + } + try { + if ($promotedBinary) { Remove-Item -LiteralPath $destinationBinary -Force -ErrorAction Stop } + if (Test-Path -LiteralPath $backupBinary -PathType Leaf) { + Move-Item -LiteralPath $backupBinary -Destination $destinationBinary -Force -ErrorAction Stop + } + } catch { $rollbackErrors.Add("binary restore failed: $($_.Exception.Message)") } + try { + if ($promotedRuntime) { Remove-Item -LiteralPath $runtimeDir -Recurse -Force -ErrorAction Stop } + if (Test-Path -LiteralPath $backupRuntime) { + Move-Item -LiteralPath $backupRuntime -Destination $runtimeDir -Force -ErrorAction Stop + } + } catch { $rollbackErrors.Add("native runtime restore failed: $($_.Exception.Message)") } + if ($previousServiceWasStopped -and (Test-Path -LiteralPath $destinationBinary -PathType Leaf)) { + try { + Set-ProcessRuntimeEnvironment $AdeHome $runtimeDir + & $destinationBinary serve --install-service | Out-Null + if ($LASTEXITCODE -ne 0) { + $rollbackErrors.Add("previous brain service restore exited with code $LASTEXITCODE") + } elseif (-not $previousServiceWasRunning) { + Stop-ServiceProcessPreservingRegistration $AdeHome + } + } catch { $rollbackErrors.Add("previous brain service restore failed: $($_.Exception.Message)") } + } + if ($rollbackErrors.Count -gt 0) { + $preserveTempForRecovery = $true + throw "ADE runtime install failed ($($installError.Exception.Message)); rollback also failed: $($rollbackErrors -join '; '). Recovery files were retained at $tempRoot" + } + throw $installError +} finally { + foreach ($name in $previousEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($name, $previousEnvironment[$name], "Process") + } + if (-not $preserveTempForRecovery) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/apps/ade-cli/scripts/package-native-deps.mjs b/apps/ade-cli/scripts/package-native-deps.mjs index 59058d60d..15eb2158f 100644 --- a/apps/ade-cli/scripts/package-native-deps.mjs +++ b/apps/ade-cli/scripts/package-native-deps.mjs @@ -41,8 +41,8 @@ function currentTarget() { } function validateTarget(target) { - if (!/^(darwin|linux)-(arm64|x64)$/.test(target)) { - throw new Error(`Unsupported runtime target '${target}'. Expected darwin-arm64, darwin-x64, linux-arm64, or linux-x64.`); + if (!/^(?:(?:darwin|linux)-(?:arm64|x64)|win32-x64)$/.test(target)) { + throw new Error(`Unsupported runtime target '${target}'. Expected darwin-arm64, darwin-x64, linux-arm64, linux-x64, or win32-x64.`); } } @@ -101,7 +101,8 @@ function isPackageForOtherTarget(packageName, target) { ? "windows" : packageTarget.platform; const { platform, arch } = targetParts(target); - return targetPlatform !== platform || packageTarget.arch !== arch; + const normalizedTargetPlatform = platform === "win32" ? "windows" : platform; + return targetPlatform !== normalizedTargetPlatform || packageTarget.arch !== arch; } function nodePtyPrebuildTarget(target) { @@ -130,7 +131,7 @@ function shouldCopyPackageEntry(packageName, sourceRoot, entry, target) { } } - if (packageName === "opencode-ai" && relative === "bin/opencode.exe") { + if (packageName === "opencode-ai" && relative === "bin/opencode.exe" && !target.startsWith("win32-")) { return false; } @@ -207,17 +208,25 @@ async function writeManifest(bundleRoot, target, packages) { await fs.writeFile(path.join(bundleRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); } -// Targets shipped as the production brain, where cr-sqlite is mandatory. A -// missing extension for one of these would silently re-ship the exact -// crsql_internal_sync_bit crash this packaging step exists to prevent, so it's -// a hard build failure rather than a warning. Other targets (not yet vendored) -// warn-and-skip until their extension is added. -const CRSQLITE_REQUIRED_TARGETS = new Set(["darwin-arm64", "darwin-x64"]); +// Sync-peer targets, where cr-sqlite is mandatory. A missing extension for one +// of these would silently re-ship the exact crsql_internal_sync_bit crash this +// packaging step exists to prevent, so it's a hard build failure rather than a +// warning. +// +// Linux is deliberately absent and is not a pending TODO. A Linux host is a +// remote runtime target that a macOS or Windows desktop drives over SSH; it is +// not a sync peer, holds no CRR state, and ships without the extension by +// design. Its brain logs `db.crsqlite_unavailable` and disables CRR triggers at +// startup. Adding linux-x64 here without also vendoring crsqlite.so would break +// every Linux runtime build. +const CRSQLITE_REQUIRED_TARGETS = new Set(["darwin-arm64", "darwin-x64", "win32-x64"]); +const CRSQLITE_EXCLUDED_TARGETS = new Set(["linux-x64", "linux-arm64"]); function crsqliteExtensionFileName(target) { const { platform } = targetParts(target); if (platform === "darwin") return "crsqlite.dylib"; if (platform === "linux") return "crsqlite.so"; + if (platform === "win32") return "crsqlite.dll"; throw new Error(`No cr-sqlite extension filename mapping for platform '${platform}' (target ${target}).`); } @@ -239,6 +248,15 @@ async function copyCrsqliteExtension(bundleRoot, target) { `apps/desktop/vendor/crsqlite/${target}/.`, ); } + if (CRSQLITE_EXCLUDED_TARGETS.has(target)) { + // Expected and intentional: this target is a remote runtime host, not a + // sync peer. Stated as a scope note so it does not read as a build defect. + process.stdout.write( + `[package-native-deps] ${target} ships without cr-sqlite by design: it is a remote ` + + `runtime target, not a sync peer, and holds no CRR state.\n`, + ); + return false; + } process.stderr.write( `[package-native-deps] WARNING: no cr-sqlite extension vendored for ${target} ` + `(${source}); the installed brain on this target will lack CRDT sync.\n`, diff --git a/apps/ade-cli/scripts/sign-windows-runtime.ps1 b/apps/ade-cli/scripts/sign-windows-runtime.ps1 new file mode 100644 index 000000000..f85bc2dd6 --- /dev/null +++ b/apps/ade-cli/scripts/sign-windows-runtime.ps1 @@ -0,0 +1,96 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BinaryPath, + # Azure Artifact Signing certificates are valid for 72 hours, so an RFC3161 + # countersignature is what keeps a released binary verifiable past that. + [string]$TimestampServer = "http://timestamp.acs.microsoft.com" +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Fail([string]$Message) { + throw "ADE Windows runtime signing: $Message" +} + +$resolvedBinary = [IO.Path]::GetFullPath($BinaryPath) +if (-not (Test-Path -LiteralPath $resolvedBinary -PathType Leaf)) { + Fail "runtime binary is missing: $resolvedBinary" +} + +# The signing key is held by Azure Artifact Signing and never leaves it, so the +# only credential this script carries is a Microsoft Entra service principal. +# These three names are exactly what Azure.Identity's EnvironmentCredential +# reads, and EnvironmentCredential is tried before any managed-identity probe, +# which a GitHub-hosted runner cannot satisfy. +$missingCredentials = @( + "AZURE_TENANT_ID", + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET" +) | Where-Object { [string]::IsNullOrWhiteSpace([string][Environment]::GetEnvironmentVariable($_)) } +if ($missingCredentials.Count -gt 0) { + Fail "$($missingCredentials -join ', ') are required to sign with Azure Artifact Signing" +} + +$signingEndpoint = ([string]$env:WINDOWS_SIGNING_ENDPOINT).Trim() +if ([string]::IsNullOrWhiteSpace($signingEndpoint)) { + $signingEndpoint = "https://eus.codesigning.azure.net" +} +$signingAccountName = ([string]$env:WINDOWS_SIGNING_ACCOUNT_NAME).Trim() +if ([string]::IsNullOrWhiteSpace($signingAccountName)) { + $signingAccountName = "arulsigning" +} +$certificateProfileName = ([string]$env:WINDOWS_SIGNING_CERTIFICATE_PROFILE).Trim() +if ([string]::IsNullOrWhiteSpace($certificateProfileName)) { + $certificateProfileName = "adePublicTrust" +} + +$expectedSubject = ([string]$env:WINDOWS_SIGNING_EXPECTED_SUBJECT).Trim() +if ([string]::IsNullOrWhiteSpace($expectedSubject)) { + Fail "WINDOWS_SIGNING_EXPECTED_SUBJECT is required so the runtime cannot be signed by an unexpected publisher" +} +# Azure Artifact Signing renews its certificate daily and expires it after 72 +# hours. A pinned thumbprint would reject every release within days, so the name +# is rejected rather than ignored - an ignored pin is a pin nobody notices is gone. +if (-not [string]::IsNullOrWhiteSpace(([string]$env:WINDOWS_SIGNING_EXPECTED_THUMBPRINT).Trim())) { + Fail "WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline; the service renews its certificate daily and expires it after 72 hours. Unset it and pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead" +} + +# Same signing mechanism electron-builder 26 uses for the desktop installer, so +# the desktop and standalone Windows artifacts go through one code path with one +# set of verified parameter names. Microsoft's successor module is +# `ArtifactSigning` (`Invoke-ArtifactSigning`), which the current GitHub Action +# uses; electron-builder 26.8.1 hardcodes `TrustedSigning` and only moves off it +# in v27, which replaces the module with `signtool /dlib`. Migrate both together +# when the desktop build moves, so the two Windows artifacts never diverge. +if (-not (Get-Module -ListAvailable -Name TrustedSigning)) { + if ($null -eq (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null + } + Install-Module -Name TrustedSigning -MinimumVersion 0.5.0 -Force -Repository PSGallery -Scope CurrentUser +} +Import-Module TrustedSigning -Force + +Invoke-TrustedSigning ` + -Endpoint $signingEndpoint ` + -CodeSigningAccountName $signingAccountName ` + -CertificateProfileName $certificateProfileName ` + -Files $resolvedBinary ` + -FileDigest "SHA256" ` + -TimestampRfc3161 $TimestampServer ` + -TimestampDigest "SHA256" + +$signature = Get-AuthenticodeSignature -LiteralPath $resolvedBinary +if ($signature.Status -ne [Management.Automation.SignatureStatus]::Valid) { + Fail "signed runtime validation failed with status $($signature.Status)" +} +if ($null -eq $signature.TimeStamperCertificate) { + Fail "signed runtime has no trusted Authenticode timestamp" +} +$actualSubject = ([string]$signature.SignerCertificate.Subject).Trim() +if (-not [string]::Equals($actualSubject, $expectedSubject, [StringComparison]::OrdinalIgnoreCase)) { + Fail "signed runtime publisher subject does not match WINDOWS_SIGNING_EXPECTED_SUBJECT" +} + +Write-Output "Signed and validated the standalone Windows runtime." diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 9289f6e7d..fbadc33a3 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2246,31 +2246,37 @@ describe("adeRpcServer", () => { }); expect(response?.isError).toBeUndefined(); - expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( - expect.objectContaining({ - laneId: "lane-1", - cols: 120, - rows: 36, - tracked: true, - toolType: "claude-orchestrated", - command: claudePath, - args: expect.arrayContaining(["--model", "claude-sonnet-5", "--permission-mode", "default"]), - env: expect.objectContaining({ - ADE_DEFAULT_ROLE: "agent", - }), - }) - ); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-1", + cols: 120, + rows: 36, + tracked: true, + toolType: "claude-orchestrated", + env: expect.objectContaining({ ADE_DEFAULT_ROLE: "agent" }), + })); + const createCall = (fixture.runtime.ptyService.create as ReturnType).mock.calls[0]?.[0] as { + command?: string; + args?: string[]; + startupCommand?: string; + }; + // Provider resolution is platform-native on every OS: POSIX goes through + // `command -v`, Windows through `where.exe` (which honours PATHEXT, hence + // the `.cmd` fixture). A resolved provider always becomes a direct + // command/args launch so worker identity rides the process env instead of + // a POSIX-only `VAR=value cmd` prefix. + expect(createCall.command).toBe(claudePath); + expect(createCall.args).toEqual(expect.arrayContaining(["--model", "claude-sonnet-5", "--permission-mode", "default"])); + expect(createCall.startupCommand).toContain("claude --model claude-sonnet-5 --permission-mode default"); // The final arg concatenates ADE_CLI_INLINE_GUIDANCE with the user prompt; assert // it ends with the user prompt and carries the inline guidance preamble. - const createCall = (fixture.runtime.ptyService.create as ReturnType).mock.calls[0]?.[0] as { args: string[] }; - const finalArg = createCall.args[createCall.args.length - 1]; - expect(finalArg).toContain("CLI controls ADE state"); - expect(finalArg).toContain("PRs, proof, apps"); - expect(finalArg).toContain("clean up started processes"); - expect(finalArg).toContain("ade chat note"); - expect(finalArg).toContain("ade chat ask"); - expect(finalArg).toContain("You cannot settle or unsettle a session"); - expect(finalArg.endsWith("Implement API wiring")).toBe(true); + const launchText = createCall.args?.at(-1) ?? createCall.startupCommand ?? ""; + expect(launchText).toContain("CLI controls ADE state"); + expect(launchText).toContain("PRs, proof, apps"); + expect(launchText).toContain("clean up started processes"); + expect(launchText).toContain("ade chat note"); + expect(launchText).toContain("ade chat ask"); + expect(launchText).toContain("You cannot settle or unsettle a session"); + expect(launchText).toContain("Implement API wiring"); expect(response.structuredContent.startupCommand).toContain("claude"); expect(response.structuredContent.startupCommand).toContain("--model"); expect(response.structuredContent.startupCommand).toContain("--permission-mode"); @@ -2297,12 +2303,12 @@ describe("adeRpcServer", () => { expect(response?.isError).toBeUndefined(); const createCall = fixture.runtime.ptyService.create.mock.calls[0]?.[0] as { args?: string[]; startupCommand?: string }; - expect(createCall.args).toEqual(expect.arrayContaining(["--sandbox", "workspace-write", "--ask-for-approval", "on-request"])); - const finalArg = createCall.args?.at(-1) ?? ""; - expect(finalArg).toContain("ade chat note"); - expect(finalArg).toContain("ade chat ask"); - expect(finalArg).toContain("You cannot settle or unsettle a session"); - expect(createCall.args).not.toContain("--full-auto"); + const launchText = createCall.args?.join(" ") ?? createCall.startupCommand ?? ""; + expect(launchText).toContain("--sandbox workspace-write --ask-for-approval on-request"); + expect(launchText).toContain("ade chat note"); + expect(launchText).toContain("ade chat ask"); + expect(launchText).toContain("You cannot settle or unsettle a session"); + expect(launchText).not.toContain("--full-auto"); expect(createCall.startupCommand).toContain("--sandbox workspace-write --ask-for-approval on-request"); expect(createCall.startupCommand).not.toContain("--full-auto"); }); @@ -2520,22 +2526,30 @@ describe("adeRpcServer", () => { }); expect(response?.isError).toBeUndefined(); - expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( - expect.objectContaining({ - laneId: "lane-1", - title: "Shell", - toolType: "shell", - command: "/bin/zsh", - args: ["-f"], - env: { ZDOTDIR: "/var/empty" }, - }), - ); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith(expect.objectContaining( + process.platform === "win32" + ? { + laneId: "lane-1", + title: "Shell", + toolType: "shell", + command: "powershell.exe", + args: ["-NoLogo", "-NoProfile"], + } + : { + laneId: "lane-1", + title: "Shell", + toolType: "shell", + command: "/bin/zsh", + args: ["-f"], + env: { ZDOTDIR: "/var/empty" }, + }, + )); }); it("starts Codex spawn_agent with current default permission flags", async () => { const fixture = createRuntime(); const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-bin-")); - createFakePathExecutable(binDir, "codex"); + const codexPath = createFakePathExecutable(binDir, "codex"); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); const response = await withEnv({ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, SHELL: "/bin/sh" }, async () => { @@ -2548,13 +2562,14 @@ describe("adeRpcServer", () => { }); expect(response?.isError).toBeUndefined(); - expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( - expect.objectContaining({ - command: expect.stringMatching(/codex$/), - args: expect.arrayContaining(["--sandbox", "workspace-write", "--ask-for-approval", "on-request"]), - startupCommand: expect.stringContaining("codex --sandbox workspace-write --ask-for-approval on-request"), - }), - ); + const createCall = fixture.runtime.ptyService.create.mock.calls[0]?.[0] as { + command?: string; + args?: string[]; + startupCommand?: string; + }; + expect(createCall.startupCommand).toContain("codex --sandbox workspace-write --ask-for-approval on-request"); + expect(createCall.command).toBe(codexPath); + expect(createCall.args).toEqual(expect.arrayContaining(["--sandbox", "workspace-write", "--ask-for-approval", "on-request"])); expect(response.structuredContent.startupCommand).not.toContain("--full-auto"); }); @@ -2793,11 +2808,15 @@ describe("adeRpcServer", () => { expect(response?.isError).toBeUndefined(); expect(response.structuredContent.startupCommand).toContain("claude"); - expect(response.structuredContent.startupCommand).toContain("ADE_RUN_ID=run-1"); - expect(response.structuredContent.startupCommand).toContain("ADE_ATTEMPT_ID=attempt-workspace-roots"); + if (process.platform === "win32") { + expect(response.structuredContent.startupCommand).not.toContain("ADE_RUN_ID=run-1"); + expect(response.structuredContent.startupCommand).not.toContain("ADE_ATTEMPT_ID=attempt-workspace-roots"); + } else { + expect(response.structuredContent.startupCommand).toContain("ADE_RUN_ID=run-1"); + expect(response.structuredContent.startupCommand).toContain("ADE_ATTEMPT_ID=attempt-workspace-roots"); + } expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( expect.objectContaining({ - command: claudePath, env: expect.objectContaining({ ADE_RUN_ID: "run-1", ADE_ATTEMPT_ID: "attempt-workspace-roots", @@ -2805,6 +2824,8 @@ describe("adeRpcServer", () => { }), }) ); + const createCall = fixture.runtime.ptyService.create.mock.calls[0]?.[0] as { command?: string }; + expect(createCall.command).toBe(claudePath); }); it("keeps spawn_agent on shell startup when the provider executable cannot be resolved", async () => { @@ -2946,7 +2967,7 @@ describe("adeRpcServer", () => { expect(response.structuredContent.startupCommand).toContain("CLI controls ADE state"); const contextPath = response.structuredContent.contextRef?.path as string | null; expect(contextPath).toBeTruthy(); - expect(contextPath?.includes("/.ade/cache/orchestrator/agent-context/run-123/")).toBe(true); + expect(contextPath?.replace(/\\/g, "/")).toContain("/.ade/cache/orchestrator/agent-context/run-123/"); if (!contextPath) { throw new Error("Expected context manifest path"); } diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index d8f022ccc..dec23e49d 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -203,6 +203,7 @@ function resolveExecutableOnPath(command: string, env: NodeJS.ProcessEnv = proce encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); if (result.status !== 0 || typeof result.stdout !== "string") return null; const first = result.stdout @@ -3507,6 +3508,7 @@ async function runTool(args: { const result = spawnSync(command, commandArgs, { cwd: runtime.projectRoot, encoding: "utf8", + windowsHide: true, env: { ...process.env, ...(options?.env ?? {}), diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 92bab15e5..f9bded5fe 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -27,6 +27,7 @@ import { resolveSnoozeUntilIso, renderLaneGraph, resolveAdeCodeModulePath, + resolveWindowsDesktopExecutable, resolveRoots, runCli, startHeadlessRpcSocketServer, @@ -42,9 +43,12 @@ import { DEVELOPMENT_ADE_CLERK_ISSUER, DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, } from "../../desktop/src/shared/accountDirectory"; +import { isAdeRuntimeNamedPipePath } from "../../desktop/src/shared/adeRuntimeIpc"; +import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { generateRpcAuthToken } from "./rpcAuth"; import { JsonRpcClient } from "./tuiClient/jsonRpcClient"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { localIpcListenOptions } from "./services/runtime/localIpcListenOptions"; type ResolveRootsOptions = Parameters[0]; @@ -474,7 +478,7 @@ describe("ADE CLI", () => { "laneId=lane-1", ]); - expect(parsed.options.projectRoot).toBe("/tmp/project"); + expect(parsed.options.projectRoot).toBe(path.resolve("/tmp/project")); expect(parsed.options.role).toBe("cto"); expect(parsed.command).toEqual([ "actions", @@ -536,7 +540,7 @@ describe("ADE CLI", () => { "code", "--print-state", ]); - expect(parsed.options.projectRoot).toBe("/tmp/project"); + expect(parsed.options.projectRoot).toBe(path.resolve("/tmp/project")); expect(parsed.command).toEqual(["code", "--print-state"]); const plan = buildCliPlan(parsed.command); @@ -788,14 +792,32 @@ describe("ADE CLI", () => { }, ); - it("returns null for a named-pipe socket path (desktop path; no dir/chmod)", async () => { - // isAdeRuntimeNamedPipePath matches by string prefix, so this exercises the - // named-pipe early-return branch on any platform without touching the fs. + it("declares intended-user-only access when listening on a Windows named pipe", () => { + expect(localIpcListenOptions("\\\\.\\pipe\\ade-headless-security-test")).toEqual({ + path: "\\\\.\\pipe\\ade-headless-security-test", + readableAll: false, + writableAll: false, + }); + expect(localIpcListenOptions("/tmp/ade.sock")).toBe("/tmp/ade.sock"); + }); + + (process.platform === "win32" ? it : it.skip)("hosts headless RPC on a Windows named pipe", async () => { + const socketPath = `\\\\.\\pipe\\ade-headless-${process.pid}-${Date.now()}`; const stop = await startHeadlessRpcSocketServer({ - socketPath: "//./pipe/ade-headless-named-pipe-test", + socketPath, createHandler: () => (async () => ({})) as never, }); - expect(stop).toBeNull(); + try { + expect(stop).not.toBeNull(); + const client = await JsonRpcClient.connect(socketPath); + try { + await expect(client.request("ping")).resolves.toEqual({}); + } finally { + client.close(); + } + } finally { + stop?.(); + } }); it("requires the per-boot bearer token on the headless TCP RPC listener", async () => { @@ -874,6 +896,42 @@ describe("ADE CLI", () => { expect(isEphemeralRuntimeSocketPath("tcp://127.0.0.1:8765")).toBe(false); }); + // Only a Windows runner can exercise this: `resolveMachineAdeLayout` yields a + // named pipe there and a filesystem socket everywhere else, so off win32 + // there is no pipe endpoint to classify. Gated by the "Test Windows CLI + // contracts" step, which already runs this file natively. + (process.platform === "win32" ? it : it.skip)( + "classifies a scratch-home named pipe as ephemeral", + () => { + const scratchHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-")); + try { + withEnv({ ADE_HOME: scratchHome }, () => { + const scratchPipe = resolveMachineAdeLayout().socketPath; + expect(isAdeRuntimeNamedPipePath(scratchPipe)).toBe(true); + expect(isEphemeralRuntimeSocketPath(scratchPipe)).toBe(true); + // Win32 treats `/` and `\` interchangeably in a pipe path and matches + // pipe names case-insensitively, so every spelling of this endpoint + // has to classify the same way. + expect( + isEphemeralRuntimeSocketPath(scratchPipe.replace(/\\/g, "/").toUpperCase()), + ).toBe(true); + // A pipe that is not this home's own endpoint stays non-ephemeral, + // so the scratch-home check cannot leak onto a real machine brain. + expect( + isEphemeralRuntimeSocketPath("\\\\.\\pipe\\ade-runtime-stable-0123456789abcdef"), + ).toBe(false); + }); + withEnv({ ADE_HOME: path.join(os.homedir(), ".ade") }, () => { + expect( + isEphemeralRuntimeSocketPath(resolveMachineAdeLayout().socketPath), + ).toBe(false); + }); + } finally { + fs.rmSync(scratchHome, { recursive: true, force: true }); + } + }, + ); + it("blocks manual service-socket runtime spawn when service mutation is disabled", () => { expect(shouldBlockManualMachineRuntimeSpawn("/Users/example/.ade-beta/sock/ade.sock", { ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", @@ -882,6 +940,9 @@ describe("ADE CLI", () => { expect(shouldBlockManualMachineRuntimeSpawn("tcp://127.0.0.1:9999", { ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", })).toBe(false); + expect(shouldBlockManualMachineRuntimeSpawn("\\\\.\\pipe\\ade-runtime-stable-test", { + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", + })).toBe(true); expect(shouldBlockManualMachineRuntimeSpawn(path.join(os.tmpdir(), "ade-code-test", "ade.sock"), { ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", })).toBe(false); @@ -5908,6 +5969,50 @@ describe("ADE CLI", () => { expect(shouldAttemptDesktopSocketConnection("//./pipe/ade-123")).toBe(true); }); + it("finds the Windows desktop executable beside a packaged CLI resource", () => { + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-windows-desktop-")); + const cliEntry = path.join(installRoot, "resources", "ade-cli", "cli.cjs"); + const appPath = path.join(installRoot, "ADE.exe"); + fs.mkdirSync(path.dirname(cliEntry), { recursive: true }); + fs.writeFileSync(cliEntry, ""); + fs.writeFileSync(appPath, ""); + + try { + expect(resolveWindowsDesktopExecutable({ + appName: "ADE", + env: {}, + execPath: path.join(installRoot, "node.exe"), + entryPath: cliEntry, + })).toBe(appPath); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }); + + it("does not reuse a Stable executable when ADE Beta was requested", () => { + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-windows-beta-desktop-")); + const stableRoot = path.join(installRoot, "Programs", "ADE"); + const stablePath = path.join(stableRoot, "ADE.exe"); + const stableEntryPath = path.join(stableRoot, "resources", "ade-cli", "cli.cjs"); + const betaPath = path.join(installRoot, "Programs", "ADE Beta", "ADE Beta.exe"); + fs.mkdirSync(path.dirname(stableEntryPath), { recursive: true }); + fs.writeFileSync(stablePath, ""); + fs.writeFileSync(stableEntryPath, ""); + fs.mkdirSync(path.dirname(betaPath), { recursive: true }); + fs.writeFileSync(betaPath, ""); + + try { + expect(resolveWindowsDesktopExecutable({ + appName: "ADE Beta", + env: { LOCALAPPDATA: installRoot }, + execPath: stablePath, + entryPath: stableEntryPath, + })).toBe(betaPath); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }); + it("renders a compact lane graph", () => { const graph = renderLaneGraph({ lanes: [ @@ -6064,7 +6169,7 @@ describe("ADE CLI", () => { kind: "screenshot", title: "Checkout complete", description: "Checkout complete", - path: "/tmp/done.png", + path: path.resolve("/tmp/done.png"), }, ], }, @@ -6823,8 +6928,8 @@ describe("ADE CLI", () => { projectRoot: null, workspaceRoot: null, }); - expect(roots.projectRoot).toBe("/explicit/project-root"); - expect(roots.workspaceRoot).toBe("/explicit/project-root"); + expect(roots.projectRoot).toBe(path.resolve("/explicit/project-root")); + expect(roots.workspaceRoot).toBe(path.resolve("/explicit/project-root")); } finally { if (prevProject === undefined) delete process.env.ADE_PROJECT_ROOT; else process.env.ADE_PROJECT_ROOT = prevProject; @@ -6865,8 +6970,8 @@ describe("ADE CLI", () => { projectRoot: null, workspaceRoot: null, }); - expect(roots.projectRoot).toBe("/explicit/project-root"); - expect(roots.workspaceRoot).toBe("/explicit/workspace-root"); + expect(roots.projectRoot).toBe(path.resolve("/explicit/project-root")); + expect(roots.workspaceRoot).toBe(path.resolve("/explicit/workspace-root")); } finally { if (prevProject === undefined) delete process.env.ADE_PROJECT_ROOT; else process.env.ADE_PROJECT_ROOT = prevProject; diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 2633aaf83..0d7b90370 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -32,6 +32,7 @@ export { readInstalledDesktopVersion } from "./commands/doctor"; import { buildDeeplink, type DeeplinkEnvelope } from "../../desktop/src/shared/deeplinks"; import { buildPairingQrPayload } from "../../desktop/src/shared/pairingQr"; import { buildWebClientPairUrl } from "../../desktop/src/shared/webClientUrl"; +import { CURSOR_CLI_EXECUTABLES } from "../../desktop/src/shared/providerCliExecutables"; import { accountMachineDisplayName, accountMachineConnectionState, @@ -56,7 +57,7 @@ import type { ListMyGitHubReposInput, ProjectBrowseInput, } from "../../desktop/src/shared/types/core"; -import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; +import { resolveMachineAdeDir, resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { markActiveHostProjectOpen } from "./services/projects/projectCatalog"; import { resolveRemoteProjectIcon } from "./services/projects/projectIconResolver"; import type { ProjectRecord } from "./services/projects/projectRegistry"; @@ -133,7 +134,10 @@ import { syncAccountAnalyticsIdentity, } from "./services/account/accountAuthService"; import { getSharedAccountAuthService } from "./services/account/sharedAccountAuthService"; -import { DEFAULT_SYNC_HOST_PORT } from "./services/sync/syncProtocol"; +import { + DEFAULT_SYNC_HOST_PORT, + SYNC_HOST_MAX_PORT, +} from "./services/sync/syncProtocol"; import { runAdeCodeRemote, takeAdeCodeRemoteArgs, @@ -540,6 +544,7 @@ function maybeRunBuiltCliFallback( cwd: CLI_PACKAGE_ROOT, env: process.env, encoding: "utf8", + windowsHide: true, }); if (buildResult.error || buildResult.status !== 0 || !isBuiltCliFresh()) { error.details.nextAction = @@ -559,6 +564,7 @@ function maybeRunBuiltCliFallback( [SOURCE_FALLBACK_ENV]: "1", }, encoding: "utf8", + windowsHide: true, }); if (rerun.error) { error.details.nextAction = @@ -1128,7 +1134,7 @@ const HELP_BY_COMMAND: Record = { and explicit remote addresses continue to work while signed out. $ ade machines list --text - $ ade machines rename "Build Mac" + $ ade machines rename "Build workstation" $ ade machines rename --clear $ ade machines connect $ ade machines connect --project @@ -1198,7 +1204,7 @@ const HELP_BY_COMMAND: Record = { $ ade desktop open Flags: - --app-name macOS app name to open. Defaults to ADE, ADE Beta, + --app-name Installed app name to open. Defaults to ADE, ADE Beta, or ADE Alpha based on the installed CLI wrapper. `, github: `${ADE_BANNER} @@ -2866,6 +2872,7 @@ function detectUnmergedLaneCreateNudge( cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }), ): string | null { const cwd = args.cwd ?? process.cwd(); @@ -12550,6 +12557,7 @@ function findProjectRoots(startDir: string): { cwd: startDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); const gitRoot = git.status === 0 ? git.stdout.trim() : ""; const fallback = gitRoot ? path.resolve(gitRoot) : path.resolve(startDir); @@ -12586,6 +12594,7 @@ function commandExists(command: string): boolean { const result = spawnSync(lookupCommand, [command], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); return result.status === 0 && result.stdout.trim().length > 0; } @@ -12724,6 +12733,7 @@ function runLocalCommand( encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000, + windowsHide: true, }); return { ok: result.status === 0, @@ -12887,7 +12897,7 @@ function checkProviderReadiness(value: unknown): ReadinessCheck { claude: commandExists("claude"), codex: commandExists("codex"), opencode: commandExists("opencode"), - cursor: commandExists("agent") || commandExists("cursor-agent"), + cursor: CURSOR_CLI_EXECUTABLES.launchCandidates.some((command) => commandExists(command)), droid: commandExists("droid"), }; const apiKeyProviders = Object.keys(apiKeys).filter((key) => @@ -13687,12 +13697,14 @@ async function startHeadlessRpcSocketServer(args: { createHandler: () => JsonRpcHandler & { dispose?: () => void }; }): Promise<(() => void) | null> { if ( - isAdeRuntimeNamedPipePath(args.socketPath) || - fs.existsSync(args.socketPath) + !isAdeRuntimeNamedPipePath(args.socketPath) + && fs.existsSync(args.socketPath) ) { return null; } - fs.mkdirSync(path.dirname(args.socketPath), { recursive: true, mode: 0o700 }); + if (!isAdeRuntimeNamedPipePath(args.socketPath)) { + fs.mkdirSync(path.dirname(args.socketPath), { recursive: true, mode: 0o700 }); + } const serverState = createHeadlessRpcServer(args.createHandler); const { server } = serverState; @@ -13707,7 +13719,7 @@ async function startHeadlessRpcSocketServer(args: { }; server.once("listening", handleListening); server.once("error", handleError); - server.listen(args.socketPath); + server.listen(localIpcListenOptions(args.socketPath)); }); if (!isAdeRuntimeNamedPipePath(args.socketPath)) { @@ -14683,23 +14695,71 @@ function normalizeRuntimeSocketPath(rawSocketPath: string): string { : path.resolve(rawSocketPath); } -function isEphemeralRuntimeSocketPath(socketPath: string): boolean { - if (socketPath.startsWith("tcp://") || isAdeRuntimeNamedPipePath(socketPath)) { - return false; - } - const normalizedSocketPath = path.resolve(socketPath); +/** + * The `ade--XXXXXX` naming convention every throwaway ADE brain already + * follows for its scratch directory under the system temp dir. + */ +const EPHEMERAL_RUNTIME_SCRATCH_PATTERN = + /(^|[/\\])ade-(stdio-rpc|code|local-runtime)[^/\\]*/; + +function isEphemeralRuntimeScratchPath(candidate: string): boolean { + const normalizedPath = path.resolve(candidate); const tmpDirs = Array.from(new Set( [os.tmpdir(), realpathSyncSafe(os.tmpdir()), "/tmp", realpathSyncSafe("/tmp")] .map((dir) => path.resolve(dir)), )); for (const tmpDir of tmpDirs) { - const relativeToTmp = path.relative(tmpDir, normalizedSocketPath); + const relativeToTmp = path.relative(tmpDir, normalizedPath); if (relativeToTmp.startsWith("..") || path.isAbsolute(relativeToTmp)) continue; - return /(^|[/\\])ade-(stdio-rpc|code|local-runtime)[^/\\]*/.test(relativeToTmp); + return EPHEMERAL_RUNTIME_SCRATCH_PATTERN.test(relativeToTmp); } return false; } +/** + * Whether this endpoint belongs to a throwaway brain rather than the machine's + * real one. An ephemeral brain is spawned with `--no-sync` and an idle-exit + * budget, and is excluded from runtime-service repair. + * + * On macOS/Linux the endpoint is `/sock/ade.sock`, so inspecting the + * socket path answers the question directly. + * + * Windows has no filesystem socket to inspect: the machine endpoint is a named + * pipe whose name is a hash of ADE_HOME, so there is no path to match and this + * used to return `false` for every pipe. What the POSIX branch is really asking + * is "does this brain belong to a scratch ADE_HOME under the temp dir", since + * the socket always lives inside that home — so on Windows we ask that question + * of the home itself, and confirm the endpoint is the pipe that home derives. + * + * Without it every Windows scratch brain was misread as the real, + * service-managed machine brain: it was spawned WITH mobile sync and so lost + * the singleton race against the user's actual brain (which the sync loop + * treats as fatal before the RPC socket is ever bound), it never idle-exited, + * and under a packaged Electron CLI it was eligible to trigger repair of the + * installed runtime service. + */ +function isEphemeralRuntimeSocketPath(socketPath: string): boolean { + if (socketPath.startsWith("tcp://")) return false; + if (isAdeRuntimeNamedPipePath(socketPath)) { + if (!isEphemeralRuntimeScratchPath(resolveMachineAdeDir())) return false; + return namedPipeComparisonKey(resolveMachineAdeLayout().socketPath) + === namedPipeComparisonKey(socketPath); + } + return isEphemeralRuntimeScratchPath(socketPath); +} + +/** + * Collapse the equivalent spellings of one Windows named pipe onto a single + * comparison key: Win32 accepts `/` and `\` interchangeably in a pipe path and + * matches pipe names case-insensitively. Mirrors the identically named helper + * in the desktop local runtime pool. + * + * This is a comparison key ONLY — never an address to connect to or listen on. + */ +function namedPipeComparisonKey(socketPath: string): string { + return socketPath.trim().replace(/\//g, "\\").toLowerCase(); +} + function realpathSyncSafe(filePath: string): string { try { return fs.realpathSync.native(filePath); @@ -14953,8 +15013,11 @@ function shouldRepairMachineRuntimeServiceBeforeSpawn( return !socketPathOverride?.trim() && process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL !== "1" && isPackagedElectronCliRuntime() - && !socketPath.startsWith("tcp://") - && !isAdeRuntimeNamedPipePath(socketPath) + && isServiceManagedMachineRuntimeSocket(socketPath); +} + +function isServiceManagedMachineRuntimeSocket(socketPath: string): boolean { + return !socketPath.startsWith("tcp://") && !isEphemeralRuntimeSocketPath(socketPath); } @@ -14963,9 +15026,7 @@ export function shouldBlockManualMachineRuntimeSpawn( env: NodeJS.ProcessEnv = process.env, ): boolean { return env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL === "1" - && !socketPath.startsWith("tcp://") - && !isAdeRuntimeNamedPipePath(socketPath) - && !isEphemeralRuntimeSocketPath(socketPath); + && isServiceManagedMachineRuntimeSocket(socketPath); } function manualMachineRuntimeSpawnBlockedError(socketPath: string): Error { @@ -15102,6 +15163,7 @@ async function spawnMachineRuntimeDaemon( detached: true, stdio: "ignore", env, + windowsHide: true, }); child.once("error", () => {}); if (child.pid != null) recordRuntimeSpawn(socketPath, child.pid); @@ -15528,6 +15590,99 @@ async function runBrainCommand( ); } +export function resolveWindowsDesktopExecutable(args: { + appName: string; + env?: NodeJS.ProcessEnv; + execPath?: string; + entryPath?: string | null; +}): string | null { + const env = args.env ?? process.env; + const execPath = args.execPath ?? process.execPath; + const entryPath = args.entryPath ?? process.argv[1] ?? null; + const requestedName = path.basename(args.appName.trim()) || "ADE"; + const appBaseName = requestedName.toLowerCase().endsWith(".exe") + ? requestedName.slice(0, -4) + : requestedName; + const executableName = `${appBaseName}.exe`; + const execBaseName = path.basename(execPath); + const currentExecutableMatchesRequest = + execBaseName.toLowerCase() === executableName.toLowerCase(); + const candidates: Array = [ + env.ADE_DESKTOP_APP_PATH?.trim() || null, + currentExecutableMatchesRequest + ? execPath + : null, + entryPath + ? path.resolve(path.dirname(entryPath), "..", "..", executableName) + : null, + env.LOCALAPPDATA + ? path.join(env.LOCALAPPDATA, "Programs", appBaseName, executableName) + : null, + env.PROGRAMFILES + ? path.join(env.PROGRAMFILES, appBaseName, executableName) + : null, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + const resolved = path.resolve(candidate); + if (fs.existsSync(resolved)) return resolved; + } + return null; +} + +async function launchWindowsDesktopApp( + executablePath: string, + appName: string, +): Promise> { + const env = { ...process.env }; + // The installed CLI wrapper runs ADE.exe as Node. Carrying this flag into + // the child would launch another CLI process instead of the desktop UI. + delete env.ELECTRON_RUN_AS_NODE; + return await new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn(executablePath, [], { + detached: true, + stdio: "ignore", + env, + windowsHide: true, + }); + } catch (error) { + resolve({ + ok: false, + platform: process.platform, + appName, + path: executablePath, + message: error instanceof Error ? error.message : String(error), + }); + return; + } + let settled = false; + const finish = (result: Record): void => { + if (settled) return; + settled = true; + resolve(result); + }; + child.once("error", (error) => finish({ + ok: false, + platform: process.platform, + appName, + path: executablePath, + message: error.message, + })); + child.once("spawn", () => { + child.unref(); + finish({ + ok: true, + platform: process.platform, + appName, + path: executablePath, + message: `Opened ${appName}.`, + }); + }); + }); +} + async function runDesktopCommand(rest: string[]): Promise { const args = [...rest]; const sub = firstPositional(args) ?? "open"; @@ -15553,12 +15708,26 @@ async function runDesktopCommand(rest: string[]): Promise { }; } + if (process.platform === "win32") { + const executablePath = resolveWindowsDesktopExecutable({ appName }); + if (!executablePath) { + return { + ok: false, + platform: process.platform, + appName, + message: + `Unable to find the installed ${appName} executable. Reinstall ADE or set ADE_DESKTOP_APP_PATH.`, + }; + } + return await launchWindowsDesktopApp(executablePath, appName); + } + return { ok: false, platform: process.platform, appName, message: - "Launching ADE desktop from the CLI is currently supported on macOS.", + "Launching ADE desktop from the CLI is currently supported on macOS and Windows.", }; } @@ -15659,7 +15828,9 @@ async function runServe( const { getRuntimeServiceStatus } = await import("./serviceManager"); return getRuntimeServiceStatus(); } - boundLaunchdLogs(path.dirname(lastFailurePathForMachine())); + if (process.platform === "darwin") { + boundLaunchdLogs(path.dirname(lastFailurePathForMachine())); + } const previousFailure = readLastFailure({ kind: "machine" }); const startupBackoffMs = computeStartupBackoffMs(previousFailure, Date.now()); if (startupBackoffMs > 0 && previousFailure) { @@ -16206,7 +16377,12 @@ async function runServe( // brain out. const { acquireSyncHostSingleton } = await import("./services/sync/syncHostSingleton"); brainSyncHostLease ??= acquireSyncHostSingleton({ projectRoot: null }); - const listenerPort = await sharedSyncListener.ensureListening([DEFAULT_SYNC_HOST_PORT]); + const listenerPort = await sharedSyncListener.ensureListening( + Array.from( + { length: SYNC_HOST_MAX_PORT - DEFAULT_SYNC_HOST_PORT + 1 }, + (_, index) => DEFAULT_SYNC_HOST_PORT + index, + ), + ); brainSyncHostLease.updatePort(listenerPort); } else if (activeScope && brainSyncHostLease) { // A scope took over hosting and holds its own lease; drop the diff --git a/apps/ade-cli/src/commands/brainUpdate.test.ts b/apps/ade-cli/src/commands/brainUpdate.test.ts index 6f077bfec..86e1f4a0e 100644 --- a/apps/ade-cli/src/commands/brainUpdate.test.ts +++ b/apps/ade-cli/src/commands/brainUpdate.test.ts @@ -1,8 +1,9 @@ import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { brainUpdateAssetUrl, detectRuntimeTarget, @@ -17,13 +18,34 @@ function sha256(value: string): string { } function checksumFileFor(target: string, binaryContent: string, archiveContent = "archive"): string { + const binaryName = `ade-${target}${target.startsWith("win32-") ? ".exe" : ""}`; return [ - `${sha256(binaryContent)} ade-${target}`, + `${sha256(binaryContent)} ${binaryName}`, `${sha256(archiveContent)} ade-${target}.native.tar.gz`, "", ].join("\n"); } +function downloadRuntimeFixture(target: string, version = "v1.2.13") { + const binaryName = `ade-${target}${target.startsWith("win32-") ? ".exe" : ""}`; + const binaryUrl = brainUpdateAssetUrl("arul28/ADE", version, binaryName); + return async (url: string, outPath: string) => { + if (url.endsWith("/SHA256SUMS")) { + fs.writeFileSync(outPath, checksumFileFor(target, binaryUrl)); + } else { + fs.writeFileSync(outPath, url.endsWith(".tar.gz") ? "archive" : url); + } + }; +} + +function extractRuntimeFixture(command: string, args: string[]) { + if (command === "tar") { + const targetDir = args[args.indexOf("-C") + 1]; + fs.mkdirSync(path.join(targetDir, "node_modules"), { recursive: true }); + } + return { status: 0, stdout: "", stderr: "" }; +} + function tempRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-brain-update-test-")); tempRoots.push(root); @@ -31,6 +53,7 @@ function tempRoot(): string { } afterEach(() => { + vi.restoreAllMocks(); for (const root of tempRoots.splice(0)) { fs.rmSync(root, { recursive: true, force: true }); } @@ -42,7 +65,8 @@ describe("brain update command", () => { expect(detectRuntimeTarget("darwin", "x86_64")).toBe("darwin-x64"); expect(detectRuntimeTarget("linux", "aarch64")).toBe("linux-arm64"); expect(detectRuntimeTarget("linux", "amd64")).toBe("linux-x64"); - expect(() => detectRuntimeTarget("win32", "x64")).toThrow(/only supported/); + expect(detectRuntimeTarget("win32", "x64")).toBe("win32-x64"); + expect(() => detectRuntimeTarget("win32", "arm64")).toThrow(/unsupported/i); }); it("builds latest and tagged release asset URLs", () => { @@ -222,6 +246,197 @@ describe("brain update command", () => { expect(fs.existsSync(tmp)).toBe(false); }); + it.each([ + { + label: "and reinstalls it afterward", + updateArgs: ["update", "--version", "v1.2.13", "--foreground"], + serviceWasInstalled: true, + failInstall: false, + ok: true, + restarted: true, + expectedServiceArgs: [["serve", "--uninstall-service"], ["serve", "--install-service"]], + }, + { + label: "without restoring a service that was not previously installed", + updateArgs: ["update", "--version", "v1.2.13", "--foreground"], + serviceWasInstalled: false, + failInstall: true, + ok: false, + restarted: false, + expectedServiceArgs: [["serve", "--uninstall-service"], ["serve", "--install-service"]], + }, + ])("stops a Windows brain before replacing its executable $label", async ({ + updateArgs, + serviceWasInstalled, + failInstall, + ok, + restarted, + expectedServiceArgs, + }) => { + const root = tempRoot(); + const tmp = path.join(root, "tmp"); + const installedBinary = path.join(root, "bin", "ade.exe"); + fs.mkdirSync(path.dirname(installedBinary), { recursive: true }); + fs.writeFileSync(installedBinary, "old-binary"); + fs.mkdirSync(tmp, { recursive: true }); + const serviceCalls: string[][] = []; + + const result = await runBrainUpdateCommand( + updateArgs, + { + env: { ADE_HOME: root }, + platform: "win32", + arch: "x64", + tmpDir: async () => tmp, + downloadFile: async (url, outPath) => { + if (url.endsWith("/SHA256SUMS")) { + fs.writeFileSync( + outPath, + checksumFileFor( + "win32-x64", + "https://github.com/arul28/ADE/releases/download/v1.2.13/ade-win32-x64.exe", + ), + ); + } else { + fs.writeFileSync(outPath, url.endsWith(".tar.gz") ? "archive" : url); + } + }, + execFile: async () => ({ stdout: "ade 1.2.13\n", stderr: "" }), + runCommand: (command, args) => { + if (command === "tar") { + const targetDir = args[args.indexOf("-C") + 1]; + fs.mkdirSync(path.join(targetDir, "node_modules"), { recursive: true }); + } else if (args.includes("--service-status")) { + return { + status: 0, + stdout: JSON.stringify({ installed: serviceWasInstalled, running: serviceWasInstalled }), + stderr: "", + }; + } else { + serviceCalls.push([command, ...args]); + if (failInstall && args.includes("--install-service")) { + return { status: 1, stdout: "", stderr: "service start failed" }; + } + } + return { status: 0, stdout: "", stderr: "" }; + }, + }, + ); + + expect(result).toMatchObject({ ok, applied: ok, restarted, target: "win32-x64" }); + expect(serviceCalls).toEqual(expectedServiceArgs.map((args) => [installedBinary, ...args])); + if (ok) { + expect(fs.readFileSync(installedBinary, "utf8")).toContain("ade-win32-x64.exe"); + } else { + expect(fs.readFileSync(installedBinary, "utf8")).toBe("old-binary"); + } + expect(fs.existsSync(path.join(root, "runtime", "win32-x64", "node_modules"))).toBe(ok); + }); + + it("rejects --no-restart on Windows before downloading or removing startup registration", async () => { + const downloadFile = vi.fn(async () => undefined); + const runCommand = vi.fn(() => ({ status: 0, stdout: "", stderr: "" })); + + await expect(runBrainUpdateCommand( + ["update", "--version", "v1.2.13", "--foreground", "--no-restart"], + { + env: { ADE_HOME: tempRoot() }, + platform: "win32", + arch: "x64", + downloadFile, + runCommand, + }, + )).rejects.toThrow(/--no-restart is not supported on Windows/); + expect(downloadFile).not.toHaveBeenCalled(); + expect(runCommand).not.toHaveBeenCalled(); + }); + + it("defers Windows staging cleanup until the staged helper executable exits", async () => { + const root = tempRoot(); + const tmp = path.join(root, "tmp"); + fs.mkdirSync(tmp, { recursive: true }); + const cleanupSpawns: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; + + const result = await runBrainUpdateCommand( + ["update", "--version", "v1.2.13", "--foreground"], + { + env: { ADE_HOME: root }, + platform: "win32", + arch: "x64", + execPath: path.join(tmp, "ade.exe"), + tmpDir: async () => tmp, + downloadFile: downloadRuntimeFixture("win32-x64"), + execFile: async () => ({ stdout: "ade 1.2.13\n", stderr: "" }), + runCommand: extractRuntimeFixture, + spawnDetached: (command, args, options) => { + cleanupSpawns.push({ command, args, env: options.env }); + }, + }, + ); + + expect(result).toMatchObject({ ok: true, applied: true, restarted: true }); + expect(fs.existsSync(tmp)).toBe(true); + expect(cleanupSpawns).toHaveLength(1); + expect(cleanupSpawns[0]).toMatchObject({ + command: "powershell.exe", + args: expect.arrayContaining(["-NonInteractive", "-Command"]), + env: expect.objectContaining({ + ADE_BRAIN_UPDATE_CLEANUP_DIR: tmp, + ADE_BRAIN_UPDATE_CLEANUP_PARENT_PID: String(process.pid), + }), + }); + expect(cleanupSpawns[0]?.args.join(" ")).toContain("Wait-Process"); + expect(cleanupSpawns[0]?.args.join(" ")).toContain("Remove-Item"); + if (process.platform === "win32") { + const cleanupScript = cleanupSpawns[0]?.args.at(-1) ?? ""; + const parsed = spawnSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", "[void][scriptblock]::Create($env:ADE_TEST_CLEANUP_SCRIPT)"], + { + encoding: "utf8", + env: { ...process.env, ADE_TEST_CLEANUP_SCRIPT: cleanupScript }, + }, + ); + expect(parsed.status, parsed.stderr).toBe(0); + } + }); + + it("keeps a completed update applied when deferred staging cleanup cannot launch", async () => { + const root = tempRoot(); + const tmp = path.join(root, "tmp"); + fs.mkdirSync(tmp, { recursive: true }); + + const result = await runBrainUpdateCommand( + ["update", "--version", "v1.2.13", "--foreground"], + { + env: { ADE_HOME: root }, + platform: "win32", + arch: "x64", + execPath: path.join(tmp, "ade.exe"), + tmpDir: async () => tmp, + downloadFile: downloadRuntimeFixture("win32-x64"), + execFile: async () => ({ stdout: "ade 1.2.13\n", stderr: "" }), + runCommand: extractRuntimeFixture, + spawnDetached: () => { + throw new Error("cleanup launch denied"); + }, + }, + ); + + expect(result).toMatchObject({ + ok: true, + applied: true, + restarted: true, + message: expect.stringContaining("Staging cleanup could not complete: cleanup launch denied"), + }); + expect(String(result.message)).toContain(`Remove ${tmp} manually.`); + expect(readBrainUpdateStatus({ ADE_HOME: root })).toMatchObject({ + state: "succeeded", + message: expect.stringContaining("Staging cleanup could not complete"), + }); + expect(fs.existsSync(tmp)).toBe(true); + }); + it("rolls back promoted assets when the service restart fails", async () => { const root = tempRoot(); const tmp = path.join(root, "tmp"); @@ -280,6 +495,103 @@ describe("brain update command", () => { }); }); + it("reports and preserves recovery files when native promotion compensation fails", async () => { + const root = tempRoot(); + const tmp = path.join(root, "tmp"); + const installedBinary = path.join(root, "bin", "ade"); + const runtimeTarget = path.join(root, "runtime", "linux-x64"); + const stagedRuntime = path.join(tmp, "runtime", "linux-x64"); + const nativeBackup = path.join(root, "runtime", `.linux-x64.previous-${process.pid}`); + fs.mkdirSync(path.dirname(installedBinary), { recursive: true }); + fs.mkdirSync(path.join(runtimeTarget, "node_modules"), { recursive: true }); + fs.writeFileSync(installedBinary, "old-binary"); + fs.mkdirSync(tmp, { recursive: true }); + + const originalRename = fs.promises.rename.bind(fs.promises); + vi.spyOn(fs.promises, "rename").mockImplementation(async (source, destination) => { + const from = String(source); + const to = String(destination); + if (from === stagedRuntime && to === runtimeTarget) { + throw new Error("native promotion denied"); + } + if (from === nativeBackup && to === runtimeTarget) { + throw new Error("native restore denied"); + } + return originalRename(source, destination); + }); + + const result = await runBrainUpdateCommand( + ["update", "--version", "v1.2.13", "--foreground", "--no-restart"], + { + env: { ADE_HOME: root }, + platform: "linux", + arch: "x64", + tmpDir: async () => tmp, + downloadFile: downloadRuntimeFixture("linux-x64"), + execFile: async () => ({ stdout: "ade 1.2.13\n", stderr: "" }), + runCommand: extractRuntimeFixture, + }, + ); + + expect(result).toMatchObject({ + ok: false, + applied: false, + message: expect.stringContaining("native promotion denied"), + }); + expect(String(result.message)).toContain("restoring the previous runtime also failed: native restore denied"); + expect(String(result.message)).toContain(`Recovery files remain at ${nativeBackup}`); + expect(fs.readFileSync(installedBinary, "utf8")).toBe("old-binary"); + expect(fs.existsSync(nativeBackup)).toBe(true); + }); + + it("continues every rollback leg and reports failed asset compensation", async () => { + const root = tempRoot(); + const tmp = path.join(root, "tmp"); + const installedBinary = path.join(root, "bin", "ade"); + const runtimeTarget = path.join(root, "runtime", "linux-x64"); + const nativeBackup = path.join(root, "runtime", `.linux-x64.previous-${process.pid}`); + fs.mkdirSync(path.dirname(installedBinary), { recursive: true }); + fs.mkdirSync(path.join(runtimeTarget, "node_modules"), { recursive: true }); + fs.writeFileSync(installedBinary, "old-binary"); + fs.mkdirSync(tmp, { recursive: true }); + + const originalRename = fs.promises.rename.bind(fs.promises); + vi.spyOn(fs.promises, "rename").mockImplementation(async (source, destination) => { + const from = String(source); + const to = String(destination); + if (from.includes(`.ade.updating-${process.pid}`) && to === installedBinary) { + throw new Error("binary promotion denied"); + } + if (from === nativeBackup && to === runtimeTarget) { + throw new Error("native rollback denied"); + } + return originalRename(source, destination); + }); + + const result = await runBrainUpdateCommand( + ["update", "--version", "v1.2.13", "--foreground", "--no-restart"], + { + env: { ADE_HOME: root }, + platform: "linux", + arch: "x64", + tmpDir: async () => tmp, + downloadFile: downloadRuntimeFixture("linux-x64"), + execFile: async () => ({ stdout: "ade 1.2.13\n", stderr: "" }), + runCommand: extractRuntimeFixture, + }, + ); + + expect(result).toMatchObject({ + ok: false, + applied: false, + message: expect.stringContaining("ADE runtime asset promotion failed (binary promotion denied)"), + }); + expect(String(result.message)).toContain("previous native runtime restoration: native rollback denied"); + expect(String(result.message)).toContain("Recovery files were retained"); + expect(fs.readFileSync(installedBinary, "utf8")).toBe("old-binary"); + expect(fs.existsSync(nativeBackup)).toBe(true); + }); + it("keeps the installed binary when staged native dependencies are incomplete", async () => { const root = tempRoot(); const tmp = path.join(root, "tmp"); diff --git a/apps/ade-cli/src/commands/brainUpdate.ts b/apps/ade-cli/src/commands/brainUpdate.ts index 85308f794..ba0ab1428 100644 --- a/apps/ade-cli/src/commands/brainUpdate.ts +++ b/apps/ade-cli/src/commands/brainUpdate.ts @@ -28,6 +28,7 @@ const SUPPORTED_TARGETS = new Set([ "darwin-x64", "linux-arm64", "linux-x64", + "win32-x64", ]); export class BrainUpdateUsageError extends Error {} @@ -83,6 +84,7 @@ export type BrainUpdateDeps = { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; arch?: string; + execPath?: string; currentVersion?: string; now?: () => Date; tmpDir?: () => Promise; @@ -108,10 +110,12 @@ export function detectRuntimeTarget( arch: string = os.arch(), ): string { const normalizedArch = normalizeArch(arch); - const normalizedPlatform = platform === "darwin" || platform === "linux" ? platform : null; + const normalizedPlatform = platform === "darwin" || platform === "linux" || platform === "win32" + ? platform + : null; if (!normalizedPlatform || !normalizedArch) { throw new BrainUpdateUsageError( - `ADE brain update is only supported on macOS and Linux arm64/x64 runtimes; got ${platform}/${arch}.`, + `ADE brain update is supported on macOS/Linux arm64/x64 and Windows x64 runtimes; got ${platform}/${arch}.`, ); } const target = `${normalizedPlatform}-${normalizedArch}`; @@ -364,6 +368,7 @@ function runCommand( cwd: options.cwd, env: options.env, encoding: "utf8", + windowsHide: true, }); return { status: result.status, @@ -394,6 +399,59 @@ export function requestBrainServiceRestart( ); } +export function requestBrainServiceStop( + args: { + command: string; + commandArgs: string[]; + env?: NodeJS.ProcessEnv; + }, + run: typeof runCommand = runCommand, +): BrainServiceRestartResult { + return run( + args.command, + [...args.commandArgs, "--uninstall-service"], + { + env: { + ...(args.env ?? process.env), + ADE_ALLOW_RUNTIME_SERVICE_SELF_MUTATION: "1", + }, + }, + ); +} + +export function requestBrainServiceStatus( + args: { + command: string; + commandArgs: string[]; + env?: NodeJS.ProcessEnv; + }, + run: typeof runCommand = runCommand, +): BrainServiceRestartResult & { installed: boolean | null } { + const result = run( + args.command, + [...args.commandArgs, "--service-status", "--json"], + { env: args.env ?? process.env }, + ); + let installed: boolean | null = null; + if (result.status === 0) { + try { + const parsed = JSON.parse(result.stdout) as { installed?: unknown }; + installed = typeof parsed.installed === "boolean" ? parsed.installed : null; + } catch { + installed = null; + } + } + return { ...result, installed }; +} + +function runtimeBinaryAssetName(target: string): string { + return `ade-${target}${target.startsWith("win32-") ? ".exe" : ""}`; +} + +function installedRuntimeBinaryName(target: string): string { + return target.startsWith("win32-") ? "ade.exe" : "ade"; +} + function runtimeNodeModulesPath(runtimeRoot: string): string { return path.join(runtimeRoot, "node_modules"); } @@ -503,7 +561,16 @@ async function promoteStagedNativeRuntime(manifest: BrainUpdateManifest): Promis return hasBackup ? backupPath : null; } catch (error) { if (hasBackup) { - await fsp.rename(backupPath, manifest.runtimeTargetDir).catch(() => undefined); + try { + await fsp.rename(backupPath, manifest.runtimeTargetDir); + } catch (restoreError) { + throw new Error( + `ADE native runtime promotion failed (${error instanceof Error ? error.message : String(error)}); ` + + `restoring the previous runtime also failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. ` + + `Recovery files remain at ${backupPath}.`, + { cause: error }, + ); + } } throw error; } @@ -513,17 +580,63 @@ async function rollbackPromotedNativeRuntime( manifest: BrainUpdateManifest, backupPath: string | null, ): Promise { - await fsp.rm(manifest.runtimeTargetDir, { recursive: true, force: true }).catch(() => undefined); + await fsp.rm(manifest.runtimeTargetDir, { recursive: true, force: true }); if (backupPath) { await fsp.rename(backupPath, manifest.runtimeTargetDir); } } +const WINDOWS_DEFERRED_STAGING_CLEANUP = [ + "$ErrorActionPreference='SilentlyContinue';", + "$target=$env:ADE_BRAIN_UPDATE_CLEANUP_DIR;", + "$parentPid=[int]$env:ADE_BRAIN_UPDATE_CLEANUP_PARENT_PID;", + "Wait-Process -Id $parentPid -ErrorAction SilentlyContinue;", + "for ($attempt=0; $attempt -lt 20; $attempt++) {", + " Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue;", + " if (-not (Test-Path -LiteralPath $target)) { exit 0 }", + " Start-Sleep -Milliseconds 500;", + "}", + "exit 1;", +].join(" "); + +function isPathInside(parentPath: string, candidatePath: string): boolean { + const relative = path.relative(path.resolve(parentPath), path.resolve(candidatePath)); + return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +async function cleanupAppliedUpdateStaging( + stagingDir: string, + deps: BrainUpdateDeps, +): Promise { + const platform = deps.platform ?? process.platform; + const execPath = deps.execPath ?? process.execPath; + if (platform === "win32" && isPathInside(stagingDir, execPath)) { + (deps.spawnDetached ?? spawnDetached)( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", WINDOWS_DEFERRED_STAGING_CLEANUP], + { + env: { + ...(deps.env ?? process.env), + ADE_BRAIN_UPDATE_CLEANUP_DIR: stagingDir, + ADE_BRAIN_UPDATE_CLEANUP_PARENT_PID: String(process.pid), + }, + }, + ); + return; + } + await fsp.rm(stagingDir, { recursive: true, force: true }); +} + async function applyStagedBrainUpdate( manifestPath: string, deps: BrainUpdateDeps, ): Promise> { const manifest = readManifest(manifestPath); + if (manifest.target === "win32-x64" && !manifest.restartService) { + throw new BrainUpdateUsageError( + "ADE brain update --no-restart is not supported on Windows because replacing the running executable requires re-registering its per-user startup service.", + ); + } const stagingDir = path.dirname(manifestPath); const runtimeDir = path.dirname(manifest.runtimeTargetDir); const statusBase = { @@ -541,12 +654,72 @@ async function applyStagedBrainUpdate( message, error, }); - const cleanupStagingDir = async () => { - await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); + const cleanupStagingDir = () => cleanupAppliedUpdateStaging(stagingDir, deps); + const cleanupStagingAfterSuccess = async (successMessage: string): Promise => { + try { + await cleanupStagingDir(); + return successMessage; + } catch (error) { + const cleanupError = error instanceof Error ? error.message : String(error); + const warningMessage = `${successMessage} Staging cleanup could not complete: ${cleanupError}. Remove ${stagingDir} manually.`; + await writeStatus("succeeded", warningMessage).catch(() => undefined); + return warningMessage; + } + }; + const platform = deps.platform ?? process.platform; + const baseEnv = deps.env ?? process.env; + let windowsServiceStopped = false; + let windowsServiceWasInstalled = false; + const serviceEnv = () => ({ + ...runtimeSidecarEnv(baseEnv, manifest.runtimeTargetDir), + ADE_HOME: manifest.adeHome, + }); + const restartRolledBackWindowsService = (): string | null => { + if (!windowsServiceStopped || !windowsServiceWasInstalled || platform !== "win32") return null; + const rollbackRestart = requestBrainServiceRestart( + { + command: manifest.binaryPath, + commandArgs: ["serve"], + env: serviceEnv(), + }, + deps.runCommand ?? runCommand, + ); + if (rollbackRestart.status === 0) return null; + return rollbackRestart.stderr || rollbackRestart.stdout || "ADE brain service rollback restart failed."; }; try { await writeStatus("applying", "Applying staged ADE brain runtime update."); + if (platform === "win32" && await pathExists(manifest.binaryPath)) { + const statusResult = requestBrainServiceStatus( + { + command: manifest.binaryPath, + commandArgs: ["serve"], + env: serviceEnv(), + }, + deps.runCommand ?? runCommand, + ); + if (statusResult.status !== 0 || statusResult.installed === null) { + throw new Error( + statusResult.stderr || statusResult.stdout || "ADE brain service state could not be read before replacing the Windows runtime.", + ); + } + windowsServiceWasInstalled = statusResult.installed; + const stopResult = requestBrainServiceStop( + { + command: manifest.binaryPath, + commandArgs: ["serve"], + env: serviceEnv(), + }, + deps.runCommand ?? runCommand, + ); + if (stopResult.status !== 0) { + throw new Error( + stopResult.stderr || stopResult.stdout || "ADE brain service could not be stopped before replacing the Windows runtime.", + ); + } + windowsServiceStopped = true; + } await fsp.mkdir(path.dirname(manifest.binaryPath), { recursive: true }); await fsp.mkdir(path.dirname(manifest.runtimeTargetDir), { recursive: true }); const replacementPath = path.join( @@ -562,15 +735,29 @@ async function applyStagedBrainUpdate( let binaryBackedUp = false; let binaryPromoted = false; const rollbackPromotedAssets = async () => { - await fsp.rm(replacementPath, { force: true }).catch(() => undefined); + const rollbackErrors: string[] = []; + const attempt = async (label: string, action: () => Promise) => { + try { + await action(); + } catch (error) { + rollbackErrors.push(`${label}: ${error instanceof Error ? error.message : String(error)}`); + } + }; + await attempt("staged binary cleanup", () => fsp.rm(replacementPath, { force: true })); if (binaryPromoted) { - await fsp.rm(manifest.binaryPath, { force: true }); + await attempt("new binary removal", () => fsp.rm(manifest.binaryPath, { force: true })); } if (binaryBackedUp) { - await fsp.rename(binaryBackupPath, manifest.binaryPath); + await attempt("previous binary restoration", () => fsp.rename(binaryBackupPath, manifest.binaryPath)); } if (nativePromoted) { - await rollbackPromotedNativeRuntime(manifest, nativeBackupPath); + await attempt( + "previous native runtime restoration", + () => rollbackPromotedNativeRuntime(manifest, nativeBackupPath), + ); + } + if (rollbackErrors.length > 0) { + throw new Error(rollbackErrors.join("; ")); } }; const discardPromotedBackups = async () => { @@ -596,14 +783,24 @@ async function applyStagedBrainUpdate( await fsp.rename(replacementPath, manifest.binaryPath); binaryPromoted = true; } catch (error) { - await rollbackPromotedAssets().catch(() => undefined); + try { + await rollbackPromotedAssets(); + } catch (rollbackError) { + throw new Error( + `ADE runtime asset promotion failed (${error instanceof Error ? error.message : String(error)}); ` + + `rollback also failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}. ` + + `Recovery files were retained beside ${manifest.binaryPath} and ${manifest.runtimeTargetDir}.`, + { cause: error }, + ); + } throw error; } if (!manifest.restartService) { - await writeStatus("succeeded", "ADE brain runtime updated. Service restart was skipped."); + const successMessage = "ADE brain runtime updated. Service restart was skipped."; + await writeStatus("succeeded", successMessage); await discardPromotedBackups(); - await cleanupStagingDir(); + const message = await cleanupStagingAfterSuccess(successMessage); return { ok: true, action: "update", @@ -613,15 +810,12 @@ async function applyStagedBrainUpdate( target: manifest.target, binaryPath: manifest.binaryPath, runtimePath: manifest.runtimeTargetDir, - message: "ADE brain runtime updated. Service restart was skipped.", + message, }; } await writeStatus("restarting", "Restarting ADE brain service with the updated runtime."); - const env = { - ...runtimeSidecarEnv(process.env, manifest.runtimeTargetDir), - ADE_HOME: manifest.adeHome, - }; + const env = serviceEnv(); const result = requestBrainServiceRestart( { command: manifest.binaryPath, @@ -638,7 +832,10 @@ async function applyStagedBrainUpdate( } catch (error) { rollbackMessage = `Rollback failed: ${error instanceof Error ? error.message : String(error)}`; } - const failureMessage = `${message} ${rollbackMessage}`; + const rollbackRestartError = restartRolledBackWindowsService(); + const failureMessage = `${message} ${rollbackMessage}${ + rollbackRestartError ? ` Previous Windows service restart also failed: ${rollbackRestartError}` : "" + }`; await writeStatus("failed", failureMessage, failureMessage); return { ok: false, @@ -653,9 +850,10 @@ async function applyStagedBrainUpdate( }; } - await writeStatus("succeeded", "ADE brain updated and service restart requested."); + const successMessage = "ADE brain updated and service restart requested."; + await writeStatus("succeeded", successMessage); await discardPromotedBackups(); - await cleanupStagingDir(); + const message = await cleanupStagingAfterSuccess(successMessage); return { ok: true, action: "update", @@ -665,10 +863,14 @@ async function applyStagedBrainUpdate( target: manifest.target, binaryPath: manifest.binaryPath, runtimePath: manifest.runtimeTargetDir, - message: "ADE brain updated and service restart requested.", + message, }; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const baseMessage = error instanceof Error ? error.message : String(error); + const rollbackRestartError = restartRolledBackWindowsService(); + const message = rollbackRestartError + ? `${baseMessage} Previous Windows service restart also failed: ${rollbackRestartError}` + : baseMessage; await writeStatus("failed", message, message).catch(() => undefined); return { ok: false, @@ -689,6 +891,7 @@ function spawnDetached(command: string, args: string[], options: SpawnOptions): ...options, detached: true, stdio: "ignore", + windowsHide: true, }); child.unref(); } @@ -715,13 +918,18 @@ export async function runBrainUpdateCommand( const layout = resolveMachineAdeLayout(env, deps.platform ?? process.platform); const target = detectRuntimeTarget(deps.platform ?? process.platform, deps.arch ?? os.arch()); - const binaryName = `ade-${target}`; - const nativeArchiveName = `${binaryName}.native.tar.gz`; + if (target === "win32-x64" && !options.restartService) { + throw new BrainUpdateUsageError( + "ADE brain update --no-restart is not supported on Windows because replacing the running executable requires re-registering its per-user startup service.", + ); + } + const binaryName = runtimeBinaryAssetName(target); + const canonicalNativeArchiveName = `ade-${target}.native.tar.gz`; const binaryUrl = brainUpdateAssetUrl(options.repo, options.version, binaryName); - const nativeArchiveUrl = brainUpdateAssetUrl(options.repo, options.version, nativeArchiveName); + const nativeArchiveUrl = brainUpdateAssetUrl(options.repo, options.version, canonicalNativeArchiveName); const checksumUrl = brainUpdateAssetUrl(options.repo, options.version, CHECKSUMS_ASSET); const installDir = path.resolve(options.installDir ?? layout.binDir); - const binaryPath = path.join(installDir, "ade"); + const binaryPath = path.join(installDir, installedRuntimeBinaryName(target)); const runtimeTargetDir = path.join(layout.runtimeDir, target); const currentVersion = deps.currentVersion ?? null; const summary = { @@ -761,8 +969,8 @@ export async function runBrainUpdateCommand( try { const tmpDir = await (deps.tmpDir ?? (() => defaultUpdateStagingDir(layout.runtimeDir)))(); - const stagedBinaryPath = path.join(tmpDir, "ade"); - const nativeArchivePath = path.join(tmpDir, nativeArchiveName); + const stagedBinaryPath = path.join(tmpDir, installedRuntimeBinaryName(target)); + const nativeArchivePath = path.join(tmpDir, canonicalNativeArchiveName); const checksumPath = path.join(tmpDir, CHECKSUMS_ASSET); const stagedRuntimeRoot = path.join(tmpDir, "runtime", target); const download = deps.downloadFile ?? defaultDownloadFile; @@ -773,7 +981,7 @@ export async function runBrainUpdateCommand( checksumPath, binaryName, binaryPath: stagedBinaryPath, - nativeArchiveName, + nativeArchiveName: canonicalNativeArchiveName, nativeArchivePath, }); await fsp.chmod(stagedBinaryPath, 0o755); diff --git a/apps/ade-cli/src/commands/deeplinks.test.ts b/apps/ade-cli/src/commands/deeplinks.test.ts index 864bf6d2b..48ee146af 100644 --- a/apps/ade-cli/src/commands/deeplinks.test.ts +++ b/apps/ade-cli/src/commands/deeplinks.test.ts @@ -1,10 +1,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CliDeeplinkUsageError, + openUrlViaOs, runDeeplinkCommand, runDeeplinkCommandAsync, runLinearInstall, @@ -12,8 +14,33 @@ import { runOpenCommand, } from "./deeplinks"; +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(() => ({ status: 0, signal: null, error: undefined })), +})); + const UUID = "550e8400-e29b-41d4-a716-446655440000"; +describe("openUrlViaOs", () => { + it.runIf(process.platform === "win32")( + "passes Windows URLs containing shell metacharacters as one opaque argv value", + () => { + const url = 'https://accounts.google.com/o/oauth2/auth?client=a b&percent=%PATH%"e="yes"&meta=^|<>()!'; + + expect(openUrlViaOs(url)).toEqual({ failed: false, message: "" }); + expect(spawnSync).toHaveBeenCalledWith( + "rundll32.exe", + ["url.dll,FileProtocolHandler", url], + { + shell: false, + stdio: "ignore", + timeout: 10_000, + windowsHide: true, + }, + ); + }, + ); +}); + describe("ade link", () => { it("emits an https lane link by default", () => { const r = runLinkCommand(["lane", UUID, "--no-clipboard"]); diff --git a/apps/ade-cli/src/commands/deeplinks.ts b/apps/ade-cli/src/commands/deeplinks.ts index 8a16c25d3..07c7aeabe 100644 --- a/apps/ade-cli/src/commands/deeplinks.ts +++ b/apps/ade-cli/src/commands/deeplinks.ts @@ -189,14 +189,23 @@ export function openUrlViaOs(url: string): { failed: boolean; message: string } cmd = "open"; args = [url]; } else if (platform === "win32") { - cmd = "cmd"; - args = ["/c", "start", "", url]; + // Pass the URL as one argv value to a native Windows protocol handler. + // OAuth URLs routinely contain cmd.exe metacharacters such as `&` and `%`; + // routing them through `cmd /c start` can split or expand the URL even when + // Node itself was spawned with shell:false. + cmd = "rundll32.exe"; + args = ["url.dll,FileProtocolHandler", url]; } else { cmd = "xdg-open"; args = [url]; } try { - const r = spawnSync(cmd, args, { stdio: "ignore", timeout: 10_000 }); + const r = spawnSync(cmd, args, { + stdio: "ignore", + timeout: 10_000, + windowsHide: true, + shell: false, + }); if (r.error) return { failed: true, message: r.error.message }; if (r.signal) return { failed: true, message: `${cmd} exited with signal ${r.signal}` }; if (typeof r.status === "number" && r.status !== 0) { diff --git a/apps/ade-cli/src/commands/doctor.test.ts b/apps/ade-cli/src/commands/doctor.test.ts index bbec2aee2..119f2f294 100644 --- a/apps/ade-cli/src/commands/doctor.test.ts +++ b/apps/ade-cli/src/commands/doctor.test.ts @@ -3,6 +3,7 @@ import { compareDoctorVersions, doctorRuntimeStatusFromInitialize, evaluateDoctorRows, + parseWindowsDesktopInstallProbe, probeDoctorBrain, type DoctorInput, } from "./doctor"; @@ -60,6 +61,19 @@ function healthyInput(): DoctorInput { } describe("doctor row evaluation", () => { + it("parses Windows installed-product discovery without accepting partial records", () => { + expect(parseWindowsDesktopInstallProbe( + '{"version":"1.2.35","path":"C:\\\\Users\\\\dev\\\\AppData\\\\Local\\\\Programs\\\\ADE\\\\ADE.exe"}', + )).toEqual({ + version: "1.2.35", + path: "C:\\Users\\dev\\AppData\\Local\\Programs\\ADE\\ADE.exe", + }); + expect(parseWindowsDesktopInstallProbe('{"version":"1.2.35"}')) + .toEqual({ version: null, path: null }); + expect(parseWindowsDesktopInstallProbe("not-json")) + .toEqual({ version: null, path: null }); + }); + it("keeps an initialized brain reachable when later health reads time out", async () => { vi.useFakeTimers(); const never = new Promise(() => {}); diff --git a/apps/ade-cli/src/commands/doctor.ts b/apps/ade-cli/src/commands/doctor.ts index 4f5b28fa0..18959bf3f 100644 --- a/apps/ade-cli/src/commands/doctor.ts +++ b/apps/ade-cli/src/commands/doctor.ts @@ -321,10 +321,10 @@ export async function probeDoctorBrain( } } -export function resolveDefaultDesktopAppName(): string { - const explicit = process.env.ADE_DESKTOP_APP_NAME?.trim(); +export function resolveDefaultDesktopAppName(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.ADE_DESKTOP_APP_NAME?.trim(); if (explicit) return explicit; - const channel = process.env.ADE_PACKAGE_CHANNEL?.trim().toLowerCase(); + const channel = env.ADE_PACKAGE_CHANNEL?.trim().toLowerCase(); if (channel === "alpha") return "ADE Alpha"; if (channel === "beta") return "ADE Beta"; return "ADE"; @@ -333,6 +333,9 @@ export function resolveDefaultDesktopAppName(): string { export function readInstalledDesktopVersion( appPaths?: string[], ): { version: string | null; path: string | null } { + if (process.platform === "win32" && !appPaths) { + return readInstalledWindowsDesktopVersion(); + } if (process.platform !== "darwin" && !appPaths) { return { version: null, path: null }; } @@ -370,6 +373,62 @@ export function readInstalledDesktopVersion( return { version: null, path: null }; } +export function parseWindowsDesktopInstallProbe( + output: string | Buffer | null | undefined, +): { version: string | null; path: string | null } { + const text = Buffer.isBuffer(output) ? output.toString("utf8").trim() : String(output ?? "").trim(); + if (!text) return { version: null, path: null }; + try { + const parsed = JSON.parse(text) as Record; + const version = asString(parsed.version); + const executablePath = asString(parsed.path); + return version && executablePath + ? { version, path: executablePath } + : { version: null, path: null }; + } catch { + return { version: null, path: null }; + } +} + +export function readInstalledWindowsDesktopVersion(args: { + env?: NodeJS.ProcessEnv; + run?: typeof spawnSync; +} = {}): { version: string | null; path: string | null } { + const env = args.env ?? process.env; + const run = args.run ?? spawnSync; + const preferredName = resolveDefaultDesktopAppName(env); + const script = [ + "$ErrorActionPreference='Stop'", + "$preferred=$env:ADE_DOCTOR_DESKTOP_NAME", + "$explicit=$env:ADE_DOCTOR_DESKTOP_PATH", + "$names=@($preferred,'ADE','ADE Beta','ADE Alpha') | Select-Object -Unique", + "$entries=@(Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -ErrorAction SilentlyContinue | Where-Object { $names -contains $_.DisplayName })", + "$entry=$entries | Sort-Object @{Expression={if ($_.DisplayName -eq $preferred) {0} else {1}}},DisplayName | Select-Object -First 1", + "$candidate=$null", + "if ($entry -and $entry.InstallLocation) { $candidate=Join-Path ([string]$entry.InstallLocation) (([string]$entry.DisplayName)+'.exe') }", + "if (-not $candidate -and $explicit) { $candidate=$explicit }", + "if (-not $candidate -and $env:LOCALAPPDATA) { $candidate=Join-Path $env:LOCALAPPDATA ('Programs\\'+$preferred+'\\'+$preferred+'.exe') }", + "$version=if ($entry -and $entry.DisplayVersion) {[string]$entry.DisplayVersion} elseif ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) {(Get-Item -LiteralPath $candidate).VersionInfo.ProductVersion} else {$null}", + "if ($version -and $candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { @{version=$version;path=[IO.Path]::GetFullPath($candidate)} | ConvertTo-Json -Compress }", + ].join("; "); + const result = run( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", script], + { + encoding: "utf8", + timeout: 5_000, + windowsHide: true, + env: { + ...env, + ADE_DOCTOR_DESKTOP_NAME: preferredName, + ADE_DOCTOR_DESKTOP_PATH: env.ADE_DESKTOP_APP_PATH?.trim() ?? "", + }, + }, + ); + if (result.status !== 0) return { version: null, path: null }; + return parseWindowsDesktopInstallProbe(result.stdout); +} + async function readLatestDesktopVersionOnline(): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), DOCTOR_ONLINE_DEADLINE_MS); diff --git a/apps/ade-cli/src/cursorCloud.ts b/apps/ade-cli/src/cursorCloud.ts index 1478e0b98..ee7fc7aeb 100644 --- a/apps/ade-cli/src/cursorCloud.ts +++ b/apps/ade-cli/src/cursorCloud.ts @@ -18,6 +18,11 @@ import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + CURSOR_WINDOWS_ARM_BLOCKER, + isCursorProviderSupported, +} from "../../desktop/src/shared/providerPlatformSupport"; + type CursorSdk = typeof import("@cursor/sdk"); const requireFromRuntime = createRequire( @@ -42,6 +47,12 @@ function isCursorSdkResolutionError(error: unknown): boolean { } async function getSdk(): Promise { + // @cursor/sdk publishes no win32-arm64 runtime, so this import can never + // succeed on Windows on ARM. Fail with the reason instead of a bare + // ERR_MODULE_NOT_FOUND. See desktop/src/shared/providerPlatformSupport.ts. + if (!isCursorProviderSupported(process.platform, process.arch)) { + throw new Error(CURSOR_WINDOWS_ARM_BLOCKER); + } if (!sdkModulePromise) { sdkModulePromise = import("@cursor/sdk") .catch((error) => { diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 2bc68ad2b..e64e14e87 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -310,7 +310,7 @@ describe("headlessLinearServices", () => { } }); - it("coalesces concurrent forced GitHub status lookups", async () => { + it("coalesces concurrent forced GitHub status lookups and lets ordinary callers join", async () => { const previousAdeHome = process.env.ADE_HOME; process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-status-coalesce-")); let resolveResponse: ((response: Response) => void) | undefined; @@ -325,10 +325,15 @@ describe("headlessLinearServices", () => { ); try { githubService.setToken("ghp_test_token"); - const lookups = Array.from( - { length: 16 }, - () => githubService.getStatus({ forceRefresh: true }), - ); + const firstForcedLookup = githubService.getStatus({ forceRefresh: true }); + const lookups = [ + firstForcedLookup, + githubService.getStatus(), + ...Array.from( + { length: 14 }, + () => githubService.getStatus({ forceRefresh: true }), + ), + ]; await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)); // The callers independently resolve the repository and credential inventory // before joining the shared HTTP probe. Keep that probe pending long enough @@ -354,6 +359,48 @@ describe("headlessLinearServices", () => { } }); + it("does not let a forced GitHub status lookup join an older ordinary lookup", async () => { + const previousAdeHome = process.env.ADE_HOME; + const previousFetch = globalThis.fetch; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-status-force-order-")); + const responseResolvers: Array<(response: Response) => void> = []; + const fetchImpl = vi.fn(async () => await new Promise((resolve) => { + responseResolvers.push(resolve); + })) as unknown as typeof fetch; + globalThis.fetch = fetchImpl; + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + ); + const responseFor = (login: string): Response => new Response(JSON.stringify({ login }), { + status: 200, + headers: { + "content-type": "application/json", + "x-oauth-scopes": "repo, workflow", + }, + }); + + try { + githubService.setToken("ghp_test_token"); + const ordinaryLookup = githubService.getStatus(); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)); + + const forcedLookup = githubService.getStatus({ forceRefresh: true }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); + responseResolvers[1]?.(responseFor("forced-user")); + await expect(forcedLookup).resolves.toMatchObject({ userLogin: "forced-user" }); + + responseResolvers[0]?.(responseFor("ordinary-user")); + await expect(ordinaryLookup).resolves.toMatchObject({ userLogin: "ordinary-user" }); + await expect(githubService.getStatus()).resolves.toMatchObject({ userLogin: "forced-user" }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + } finally { + globalThis.fetch = previousFetch; + if (previousAdeHome == null) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; + } + }); + it("creates secret gists through the headless GitHub service", async () => { const previousAdeHome = process.env.ADE_HOME; const previousFetch = globalThis.fetch; diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index f3e74df48..75e81d56f 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -444,6 +444,7 @@ function runCommandAsync( encoding: "utf8", timeout: options.timeoutMs, maxBuffer: options.maxBuffer ?? 10 * 1024 * 1024, + windowsHide: true, }, (error, stdout, stderr) => { resolve({ @@ -473,6 +474,7 @@ function ghAuthToken(): Pick 0) { @@ -677,6 +679,10 @@ export function createHeadlessGitHubService( binding: string; promise: Promise; } | null = null; + let forcedStatusLookupInFlight: { + generation: number; + promise: Promise; + } | null = null; const invalidateStatusCache = (): void => { cachedStatus = null; @@ -1586,255 +1592,113 @@ export function createHeadlessGitHubService( }); }; - service = { - verifyStoredPat, - async getStatus(opts: { forceRefresh?: boolean } = {}) { - if (opts.forceRefresh) { - invalidateStatusCache(); - } - const [origin, inventory] = await Promise.all([ - readGitOriginAsync(projectRoot), - readCredentialInventoryAsync(), - ]); - const repo = parseGitHubRepoFromRemoteUrl(origin ?? ""); - const hasOrigin = Boolean(origin); - const inventoryFailuresBySource = new Map( - inventory.failures.map((failure) => [failure.source, failure] as const), - ); - const binding = `${githubCredentialInventoryKey(inventory.candidates)}:${repo?.owner ?? ""}/${repo?.name ?? ""}`; - const now = Date.now(); - if ( - !opts.forceRefresh - && cachedStatus - && cachedStatusBinding === binding - && now - cachedAt < 30_000 - ) { - const cachedReadCandidate = cachedStatus.authSource === "none" - ? null - : inventory.candidates.find( - (candidate) => candidate.source === cachedStatus?.authSource, - ) ?? null; - const cachedWriteSource = cachedStatus.writeAuthSource - && cachedStatus.writeAuthSource !== "none" - ? cachedStatus.writeAuthSource - : null; - const cachedWriteCandidate = cachedWriteSource == null - ? null - : inventory.candidates.find((candidate) => candidate.source === cachedWriteSource) ?? null; - const cachedReadUnavailable = cachedStatus.authSource !== "none" - && (!cachedReadCandidate || githubCredentialCooldown( - cachedReadCandidate, - now, - { resource: "core" }, - ) != null); - const cachedWriteUnavailable = cachedWriteSource != null - && (!cachedWriteCandidate || githubCredentialCooldown( - cachedWriteCandidate, - now, - { resource: "core" }, - ) != null); - if (!cachedReadUnavailable && !cachedWriteUnavailable) { - const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); - const pauseUntilMs = githubBackgroundRequestPauseUntilMs(now, readCandidates); - return { - ...cachedStatus, - repo, - hasOrigin, - patTokenStored: inventory.patTokenStored, - ghCliPath: inventory.ghCliPath ?? cachedStatus.ghCliPath, - ghAuthError: inventory.ghAuthError, - credentialStates: githubCredentialStates({ - candidates: inventory.candidates, - availableSources: inventory.availableSources, - sourceFailures: inventoryFailuresBySource, - activeReadSource: cachedStatus.authSource === "none" - ? null - : cachedStatus.authSource, - activeWriteSource: cachedWriteSource, - }), - backgroundRefreshPausedUntil: pauseUntilMs == null - ? null - : new Date(pauseUntilMs).toISOString(), - }; - } - cachedStatus = null; - cachedAt = 0; - cachedStatusBinding = null; - } - const generation = statusLookupGeneration; - if ( - statusLookupInFlight?.generation === generation - && statusLookupInFlight.binding === binding - ) { - return await statusLookupInFlight.promise; - } - - const lookup = (async (): Promise => { + const performStatusLookup = async ( + forceRefresh: boolean, + generation: number, + ): Promise => { + const [origin, inventory] = await Promise.all([ + readGitOriginAsync(projectRoot), + readCredentialInventoryAsync(), + ]); + const repo = parseGitHubRepoFromRemoteUrl(origin ?? ""); + const hasOrigin = Boolean(origin); + const inventoryFailuresBySource = new Map( + inventory.failures.map((failure) => [failure.source, failure] as const), + ); + const binding = `${githubCredentialInventoryKey(inventory.candidates)}:${repo?.owner ?? ""}/${repo?.name ?? ""}`; + const now = Date.now(); + if ( + !forceRefresh + && cachedStatus + && cachedStatusBinding === binding + && now - cachedAt < 30_000 + ) { + const cachedReadCandidate = cachedStatus.authSource === "none" + ? null + : inventory.candidates.find( + (candidate) => candidate.source === cachedStatus?.authSource, + ) ?? null; + const cachedWriteSource = cachedStatus.writeAuthSource + && cachedStatus.writeAuthSource !== "none" + ? cachedStatus.writeAuthSource + : null; + const cachedWriteCandidate = cachedWriteSource == null + ? null + : inventory.candidates.find((candidate) => candidate.source === cachedWriteSource) ?? null; + const cachedReadUnavailable = cachedStatus.authSource !== "none" + && (!cachedReadCandidate || githubCredentialCooldown( + cachedReadCandidate, + now, + { resource: "core" }, + ) != null); + const cachedWriteUnavailable = cachedWriteSource != null + && (!cachedWriteCandidate || githubCredentialCooldown( + cachedWriteCandidate, + now, + { resource: "core" }, + ) != null); + if (!cachedReadUnavailable && !cachedWriteUnavailable) { const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); - const writeCandidates = githubOperationCredentialCandidates(inventory.candidates, "write"); - const statusCooldown = (candidate: HeadlessGitHubTokenCandidate) => opts.forceRefresh === true - ? githubCredentialRateLimitCooldown(candidate, Date.now(), { resource: "core" }) - : githubCredentialCooldown(candidate, Date.now(), { resource: "core" }); - const primaryCandidate = readCandidates[0] ?? null; - if (!primaryCandidate) { - const failure = inventory.failures[0] ?? null; - return { - tokenStored: inventory.appTokenStored, - patTokenStored: inventory.patTokenStored, - tokenDecryptionFailed, - storageScope: "app", - authSource: failure?.source ?? "none", - writeAuthSource: "none", - tokenType: "unknown", - repo, - hasOrigin, - userLogin: null, - scopes: [], - ghCliPath: inventory.ghCliPath, - ghAuthError: inventory.ghAuthError, - checkedAt: null, - authFailure: failure?.authFailure ?? null, - rateLimit: failure?.rateLimit ?? null, - credentialStates: githubCredentialStates({ - candidates: inventory.candidates, - availableSources: inventory.availableSources, - sourceFailures: inventoryFailuresBySource, - activeReadSource: null, - activeWriteSource: null, - }), - credentialFallback: null, - backgroundRefreshPausedUntil: null, - repoAccessOk: null, - repoAccessError: null, - connected: false, - }; - } - - const { active, activeWrite, failures } = await resolveGithubStatusCredentials({ - readCandidates, - writeCandidates, - cooldown: statusCooldown, - probe: (candidate) => probeCandidate(candidate, repo, opts.forceRefresh === true), - capabilities: (candidate, value) => validatedCredentialCapabilities( - candidate, - value, - repo, - ), - isRepositoryAccessFailure: (result) => result.authFailure.kind === "permission_denied" - && result.value?.repoAccessOk === false, - onAuthenticatedProbe: (candidate, value) => { - registerGithubCredentialIdentity(candidate, value.validated.userLogin); - }, - onUsableProbe: (candidate, value) => { - recordGithubCredentialProbeSuccess( - candidate, - value.validated.rateLimit, - value.validated.userLogin, - ); - }, - onRejectedProbe: (candidate, result, context) => { - if (!context.repositoryAccessFailure) { - recordGithubCredentialFailure(candidate, result.authFailure, result.rateLimit); - } - if (context.phase === "read") { - logger.warn("github.token_validation_failed", { - source: candidate.source, - error: result.error, - kind: result.authFailure.kind, - retryAt: result.authFailure.retryAt, - }); - } - }, - }); - const activeWriteSource = activeWrite?.source ?? null; - const credentialFailures = [ - ...inventory.failures, - ...failures.map((failure) => ({ - source: failure.candidate.source, - authFailure: failure.authFailure, - rateLimit: failure.rateLimit, - })), - ]; - const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); - if (active) { - const { candidate, value } = active; - const { validated, repoAccessOk, repoAccessError } = value; - const failuresBySource = new Map( - credentialFailures.map((failure) => [failure.source, failure] as const), - ); - const activePrecedenceIndex = githubOperationCredentialPrecedence("read") - .indexOf(candidate.source); - const fallbackFailure = githubOperationCredentialPrecedence("read") - .slice(0, activePrecedenceIndex) - .map((source) => failuresBySource.get(source) ?? null) - .find((failure) => failure != null) ?? null; - return { - tokenStored: true, - patTokenStored: inventory.patTokenStored, - tokenDecryptionFailed: false, - storageScope: "app", - authSource: candidate.source, - writeAuthSource: activeWriteSource ?? "none", - writeUserLogin: activeWrite?.value.validated.userLogin ?? null, - tokenType: validated.tokenType, - repo, - hasOrigin, - userLogin: validated.userLogin, - scopes: validated.scopes, - ghCliPath: inventory.ghCliPath, - ghAuthError: inventory.ghAuthError, - checkedAt: new Date(now).toISOString(), - authFailure: null, - rateLimit: validated.rateLimit, - credentialStates: githubCredentialStates({ - candidates: inventory.candidates, - availableSources: inventory.availableSources, - sourceFailures: inventoryFailuresBySource, - activeReadSource: candidate.source, - activeWriteSource, - }), - credentialFallback: fallbackFailure - ? { - capability: "read", - fromSource: fallbackFailure.source, - toSource: candidate.source, - reason: fallbackFailure.authFailure.kind, - retryAt: fallbackFailure.authFailure.retryAt, - } - : null, - backgroundRefreshPausedUntil: pauseUntilMs == null + const pauseUntilMs = githubBackgroundRequestPauseUntilMs(now, readCandidates); + return { + ...cachedStatus, + repo, + hasOrigin, + patTokenStored: inventory.patTokenStored, + ghCliPath: inventory.ghCliPath ?? cachedStatus.ghCliPath, + ghAuthError: inventory.ghAuthError, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, + activeReadSource: cachedStatus.authSource === "none" ? null - : new Date(pauseUntilMs).toISOString(), - repoAccessOk, - repoAccessError, - connected: validatedCredentialCapabilities(candidate, value, repo).read, - }; - } + : cachedStatus.authSource, + activeWriteSource: cachedWriteSource, + }), + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), + }; + } + cachedStatus = null; + cachedAt = 0; + cachedStatusBinding = null; + } + if ( + !forceRefresh + && statusLookupInFlight?.generation === generation + && statusLookupInFlight.binding === binding + ) { + return await statusLookupInFlight.promise; + } - const failure = credentialFailures.find((entry) => entry.authFailure.kind === "rate_limited") - ?? credentialFailures[0] - ?? { - source: primaryCandidate.source, - authFailure: classifyGitHubAuthFailure({ message: "GitHub authentication could not be verified." }).authFailure, - rateLimit: null, - }; + const lookup = (async (): Promise => { + const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); + const writeCandidates = githubOperationCredentialCandidates(inventory.candidates, "write"); + const statusCooldown = (candidate: HeadlessGitHubTokenCandidate) => forceRefresh + ? githubCredentialRateLimitCooldown(candidate, Date.now(), { resource: "core" }) + : githubCredentialCooldown(candidate, Date.now(), { resource: "core" }); + const primaryCandidate = readCandidates[0] ?? null; + if (!primaryCandidate) { + const failure = inventory.failures[0] ?? null; return { - tokenStored: true, + tokenStored: inventory.appTokenStored, patTokenStored: inventory.patTokenStored, - tokenDecryptionFailed: false, + tokenDecryptionFailed, storageScope: "app", - authSource: primaryCandidate.source, + authSource: failure?.source ?? "none", writeAuthSource: "none", - tokenType: getTokenType(primaryCandidate.token), + tokenType: "unknown", repo, hasOrigin, userLogin: null, scopes: [], ghCliPath: inventory.ghCliPath, ghAuthError: inventory.ghAuthError, - checkedAt: new Date(now).toISOString(), - authFailure: failure.authFailure, - rateLimit: failure.rateLimit, + checkedAt: null, + authFailure: failure?.authFailure ?? null, + rateLimit: failure?.rateLimit ?? null, credentialStates: githubCredentialStates({ candidates: inventory.candidates, availableSources: inventory.availableSources, @@ -1843,27 +1707,191 @@ export function createHeadlessGitHubService( activeWriteSource: null, }), credentialFallback: null, - backgroundRefreshPausedUntil: pauseUntilMs == null - ? null - : new Date(pauseUntilMs).toISOString(), + backgroundRefreshPausedUntil: null, repoAccessOk: null, repoAccessError: null, connected: false, }; - })(); + } + + const { active, activeWrite, failures } = await resolveGithubStatusCredentials({ + readCandidates, + writeCandidates, + cooldown: statusCooldown, + probe: (candidate) => probeCandidate(candidate, repo, forceRefresh), + capabilities: (candidate, value) => validatedCredentialCapabilities( + candidate, + value, + repo, + ), + isRepositoryAccessFailure: (result) => result.authFailure.kind === "permission_denied" + && result.value?.repoAccessOk === false, + onAuthenticatedProbe: (candidate, value) => { + registerGithubCredentialIdentity(candidate, value.validated.userLogin); + }, + onUsableProbe: (candidate, value) => { + recordGithubCredentialProbeSuccess( + candidate, + value.validated.rateLimit, + value.validated.userLogin, + ); + }, + onRejectedProbe: (candidate, result, context) => { + if (!context.repositoryAccessFailure) { + recordGithubCredentialFailure(candidate, result.authFailure, result.rateLimit); + } + if (context.phase === "read") { + logger.warn("github.token_validation_failed", { + source: candidate.source, + error: result.error, + kind: result.authFailure.kind, + retryAt: result.authFailure.retryAt, + }); + } + }, + }); + const activeWriteSource = activeWrite?.source ?? null; + const credentialFailures = [ + ...inventory.failures, + ...failures.map((failure) => ({ + source: failure.candidate.source, + authFailure: failure.authFailure, + rateLimit: failure.rateLimit, + })), + ]; + const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); + if (active) { + const { candidate, value } = active; + const { validated, repoAccessOk, repoAccessError } = value; + const failuresBySource = new Map( + credentialFailures.map((failure) => [failure.source, failure] as const), + ); + const activePrecedenceIndex = githubOperationCredentialPrecedence("read") + .indexOf(candidate.source); + const fallbackFailure = githubOperationCredentialPrecedence("read") + .slice(0, activePrecedenceIndex) + .map((source) => failuresBySource.get(source) ?? null) + .find((failure) => failure != null) ?? null; + return { + tokenStored: true, + patTokenStored: inventory.patTokenStored, + tokenDecryptionFailed: false, + storageScope: "app", + authSource: candidate.source, + writeAuthSource: activeWriteSource ?? "none", + writeUserLogin: activeWrite?.value.validated.userLogin ?? null, + tokenType: validated.tokenType, + repo, + hasOrigin, + userLogin: validated.userLogin, + scopes: validated.scopes, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, + checkedAt: new Date(now).toISOString(), + authFailure: null, + rateLimit: validated.rateLimit, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, + activeReadSource: candidate.source, + activeWriteSource, + }), + credentialFallback: fallbackFailure + ? { + capability: "read", + fromSource: fallbackFailure.source, + toSource: candidate.source, + reason: fallbackFailure.authFailure.kind, + retryAt: fallbackFailure.authFailure.retryAt, + } + : null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), + repoAccessOk, + repoAccessError, + connected: validatedCredentialCapabilities(candidate, value, repo).read, + }; + } + + const failure = credentialFailures.find((entry) => entry.authFailure.kind === "rate_limited") + ?? credentialFailures[0] + ?? { + source: primaryCandidate.source, + authFailure: classifyGitHubAuthFailure({ message: "GitHub authentication could not be verified." }).authFailure, + rateLimit: null, + }; + return { + tokenStored: true, + patTokenStored: inventory.patTokenStored, + tokenDecryptionFailed: false, + storageScope: "app", + authSource: primaryCandidate.source, + writeAuthSource: "none", + tokenType: getTokenType(primaryCandidate.token), + repo, + hasOrigin, + userLogin: null, + scopes: [], + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, + checkedAt: new Date(now).toISOString(), + authFailure: failure.authFailure, + rateLimit: failure.rateLimit, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, + activeReadSource: null, + activeWriteSource: null, + }), + credentialFallback: null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), + repoAccessOk: null, + repoAccessError: null, + connected: false, + }; + })(); + if (!forceRefresh) { statusLookupInFlight = { generation, binding, promise: lookup }; + } + try { + const status = await lookup; + if (statusLookupGeneration === generation) { + cachedStatus = status; + cachedAt = Date.now(); + cachedStatusBinding = binding; + } + return status; + } finally { + if (!forceRefresh && statusLookupInFlight?.promise === lookup) { + statusLookupInFlight = null; + } + } + }; + + service = { + verifyStoredPat, + async getStatus(opts: { forceRefresh?: boolean } = {}) { + const forceRefresh = opts.forceRefresh === true; + if (forcedStatusLookupInFlight?.generation === statusLookupGeneration) { + return await forcedStatusLookupInFlight.promise; + } + if (!forceRefresh) { + return await performStatusLookup(false, statusLookupGeneration); + } + + invalidateStatusCache(); + const generation = statusLookupGeneration; + const lookup = performStatusLookup(true, generation); + forcedStatusLookupInFlight = { generation, promise: lookup }; try { - const status = await lookup; - if (statusLookupGeneration === generation) { - cachedStatus = status; - cachedAt = Date.now(); - cachedStatusBinding = binding; - } - return status; + return await lookup; } finally { - if (statusLookupInFlight?.promise === lookup) { - statusLookupInFlight = null; - } + if (forcedStatusLookupInFlight?.promise === lookup) forcedStatusLookupInFlight = null; } }, async getBackgroundRequestPauseUntilMs() { diff --git a/apps/ade-cli/src/lib/clipboard.ts b/apps/ade-cli/src/lib/clipboard.ts index dd6efa1dd..1a76f9de7 100644 --- a/apps/ade-cli/src/lib/clipboard.ts +++ b/apps/ade-cli/src/lib/clipboard.ts @@ -13,7 +13,7 @@ export type CopyToClipboardOptions = { * Test seam: override the spawn function. The override must return the * same shape as `spawnSync` (status + error). Defaults to `spawnSync`. */ - spawn?: (cmd: string, args: string[], options: { input: string }) => { + spawn?: (cmd: string, args: string[], options: { input: string; windowsHide?: boolean }) => { error?: Error; status?: number | null; }; @@ -50,7 +50,7 @@ export function copyToClipboard(text: string, options: CopyToClipboardOptions = return false; } } - const r = spawn(cmd, args, { input: text }); + const r = spawn(cmd, args, { input: text, windowsHide: true }); if (r.error || (typeof r.status === "number" && r.status !== 0)) return false; return true; } @@ -58,6 +58,7 @@ export function copyToClipboard(text: string, options: CopyToClipboardOptions = function defaultCommandExists(cmd: string): boolean { const r = spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore", + windowsHide: true, }); return !r.error && r.status === 0; } diff --git a/apps/ade-cli/src/services/agentRegistry.test.ts b/apps/ade-cli/src/services/agentRegistry.test.ts index 0d7e8d9dd..f92aebe58 100644 --- a/apps/ade-cli/src/services/agentRegistry.test.ts +++ b/apps/ade-cli/src/services/agentRegistry.test.ts @@ -1,9 +1,20 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { classifyAgentCliError } from "./agentRegistry"; +const originalPlatform = process.platform; + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value, configurable: true }); +} + +afterEach(() => setPlatform(originalPlatform)); + describe("classifyAgentCliError", () => { - it("classifies missing agent CLIs with install/auth commands", () => { - expect(classifyAgentCliError("spawn codex ENOENT")).toMatchObject({ + it("classifies missing agent CLIs with POSIX install/auth commands", async () => { + setPlatform("linux"); + vi.resetModules(); + const { classifyAgentCliError: classifyForLinux } = await import("./agentRegistry"); + expect(classifyForLinux("spawn codex ENOENT")).toMatchObject({ agent: "codex", displayName: "Codex CLI", category: "missing", @@ -14,6 +25,31 @@ describe("classifyAgentCliError", () => { }); }); + it("uses Windows-native recovery commands without POSIX shell setup", async () => { + setPlatform("win32"); + vi.resetModules(); + const { classifyAgentCliError: classifyForWindows } = await import("./agentRegistry"); + expect(classifyForWindows("spawn codex ENOENT")).toMatchObject({ + agent: "codex", + category: "missing", + installCommand: "npm install -g @openai/codex", + authCommand: "codex login", + }); + expect(classifyForWindows("spawn cursor-agent ENOENT", "cursor")).toMatchObject({ + agent: "cursor", + category: "missing", + installCommand: `powershell.exe -NoProfile -Command "irm 'https://cursor.com/install?win32=true' | iex"`, + authCommand: "cursor-agent login", + }); + expect(classifyForWindows("'droid.cmd' is not recognized as an internal or external command")).toMatchObject({ + agent: "droid", + displayName: "Factory Droid", + category: "missing", + installCommand: "npm install -g droid", + authCommand: "droid", + }); + }); + it("classifies unauthenticated agent CLIs with auth commands", () => { expect(classifyAgentCliError("codex failed: login required")).toMatchObject({ agent: "codex", @@ -32,6 +68,40 @@ describe("classifyAgentCliError", () => { }); }); + it("provides Factory Droid install and interactive authentication recovery", () => { + expect(classifyAgentCliError("spawn droid ENOENT")).toMatchObject({ + agent: "droid", + displayName: "Factory Droid", + category: "missing", + authCommand: "droid", + }); + expect(classifyAgentCliError("Factory Droid authentication failed: login required")).toMatchObject({ + agent: "droid", + displayName: "Factory Droid", + category: "unauthenticated", + authCommand: "droid", + }); + expect(classifyAgentCliError("No Factory API key was found", "droid")).toMatchObject({ + agent: "droid", + category: "unauthenticated", + authCommand: "droid", + }); + expect(classifyAgentCliError("Factory Droid completed successfully", "droid")).toBeNull(); + }); + + it("uses the installed legacy Cursor alias for authentication recovery", () => { + expect(classifyAgentCliError("agent failed: login required", "cursor")).toMatchObject({ + agent: "cursor", + category: "unauthenticated", + authCommand: "agent login", + }); + expect(classifyAgentCliError("Cursor agent failed: login required", "cursor")).toMatchObject({ + agent: "cursor", + category: "unauthenticated", + authCommand: "cursor-agent login", + }); + }); + it("classifies legacy Claude login hints", () => { expect(classifyAgentCliError("Please run 'claude /login'", "claude")).toMatchObject({ agent: "claude", diff --git a/apps/ade-cli/src/services/agentRegistry.ts b/apps/ade-cli/src/services/agentRegistry.ts index f33b1e016..415260a31 100644 --- a/apps/ade-cli/src/services/agentRegistry.ts +++ b/apps/ade-cli/src/services/agentRegistry.ts @@ -1,11 +1,17 @@ +import { CURSOR_CLI_EXECUTABLES } from "../../../desktop/src/shared/providerCliExecutables"; + export type AgentCliErrorCategory = "missing" | "unauthenticated"; export type AgentCliDescriptor = { agent: string; displayName: string; - binaryNames: string[]; + binaryNames: readonly string[]; installCommand: string; authCommand: string; + authRecoveryRules?: readonly { + authCommand: string; + patterns: readonly RegExp[]; + }[]; missingErrorPatterns: RegExp[]; notAuthErrorPatterns: RegExp[]; }; @@ -25,6 +31,13 @@ function npmGlobalInstallCommand(packageName: string): string { return `mkdir -p "$HOME/.npm-global" "$HOME/.local/bin" && NPM_CONFIG_PREFIX="$HOME/.npm-global" npm install -g ${packageName}`; } +function cursorInstallCommand(): string { + if (typeof process !== "undefined" && process.platform === "win32") { + return `powershell.exe -NoProfile -Command "irm 'https://cursor.com/install?win32=true' | iex"`; + } + return 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash'; +} + export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [ { agent: "claude", @@ -75,9 +88,10 @@ export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [ { agent: "cursor", displayName: "Cursor Agent", - binaryNames: ["cursor-agent", "cursor"], - installCommand: 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash', + binaryNames: CURSOR_CLI_EXECUTABLES.recoveryMentionNames, + installCommand: cursorInstallCommand(), authCommand: "cursor-agent login", + authRecoveryRules: CURSOR_CLI_EXECUTABLES.authRecoveryRules, missingErrorPatterns: [ /\bcursor-agent\b.*\b(command not found|not recognized|not found|enoent)\b/i, /\bcursor\b.*\b(command not found|not recognized|enoent)\b/i, @@ -87,6 +101,26 @@ export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [ /\bcursor(?:-agent)?\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, ], }, + { + agent: "droid", + displayName: "Factory Droid", + binaryNames: ["droid"], + installCommand: npmGlobalInstallCommand("droid"), + // Factory exposes sign-in through the interactive CLI's /login flow rather + // than a non-interactive `login` subcommand. Launching `droid` is therefore + // the portable recovery command on Windows, macOS, and Linux. + authCommand: "droid", + missingErrorPatterns: [ + /\bdroid\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+droid\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bdroid\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + /\bfactory\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + /\b(?:invalid|missing|no)\s+factory(?:_api_key| api key)\b/i, + /\bfactory(?:_api_key| api key)\b.*\b(invalid|missing|not found|not set|required|unauthorized|must be set)\b/i, + ], + }, ]; function descriptorMatchesPreferred(descriptor: AgentCliDescriptor, preferredAgent: string | null | undefined): boolean { @@ -98,17 +132,24 @@ function descriptorMatchesPreferred(descriptor: AgentCliDescriptor, preferredAge } function descriptorMentioned(descriptor: AgentCliDescriptor, text: string): boolean { - return descriptor.binaryNames.some((name) => new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text)) + return descriptor.binaryNames.some((name) => binaryNameMentioned(text, name)) || new RegExp(`\\b${descriptor.agent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text); } -function toMatch(descriptor: AgentCliDescriptor, category: AgentCliErrorCategory): AgentCliErrorMatch { +function binaryNameMentioned(text: string, name: string): boolean { + return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\.exe|\\.cmd|\\.bat|\\.ps1)?\\b`, "i").test(text); +} + +function toMatch(descriptor: AgentCliDescriptor, category: AgentCliErrorCategory, text: string): AgentCliErrorMatch { + const aliasAuthCommand = category === "unauthenticated" + ? descriptor.authRecoveryRules?.find((rule) => rule.patterns.some((pattern) => pattern.test(text)))?.authCommand + : undefined; return { agent: descriptor.agent, displayName: descriptor.displayName, category, installCommand: descriptor.installCommand, - authCommand: descriptor.authCommand, + authCommand: aliasAuthCommand ?? descriptor.authCommand, }; } @@ -124,10 +165,10 @@ export function classifyAgentCliError(message: string, preferredAgent?: string | const mentioned = descriptorMentioned(descriptor, text); if (!mentioned && descriptor !== preferred) continue; if (descriptor.missingErrorPatterns.some((pattern) => pattern.test(text))) { - return toMatch(descriptor, "missing"); + return toMatch(descriptor, "missing", text); } if (descriptor.notAuthErrorPatterns.some((pattern) => pattern.test(text))) { - return toMatch(descriptor, "unauthenticated"); + return toMatch(descriptor, "unauthenticated", text); } } @@ -136,10 +177,10 @@ export function classifyAgentCliError(message: string, preferredAgent?: string | /\b(command not found|not recognized|enoent|executable file not found|no such file or directory)\b/i.test(text) || /\b(?:spawn|exec(?:ute)?|binary|command|executable)\b.*\bnot found\b/i.test(text) ) { - return toMatch(preferred, "missing"); + return toMatch(preferred, "missing", text); } if (/\b(not logged in|not authenticated|unauthorized|authentication failed|login required|invalid api key|401|403)\b/i.test(text)) { - return toMatch(preferred, "unauthenticated"); + return toMatch(preferred, "unauthenticated", text); } } diff --git a/apps/ade-cli/src/services/credentials/credentialStore.test.ts b/apps/ade-cli/src/services/credentials/credentialStore.test.ts index dbcce21da..187ce4498 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.test.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.test.ts @@ -10,6 +10,11 @@ import { KeytarCredentialStore, createDefaultCredentialStore, } from "./credentialStore"; +import { + readOrCreateWindowsDpapiMaterial, + readOrCreateWindowsDpapiMaterialAsync, + resolveWindowsDpapiPowerShellPath, +} from "./windowsDpapiMaterial"; let tempDir = ""; @@ -22,6 +27,93 @@ afterEach(() => { }); describe("EncryptedFileCredentialStore", () => { + it.runIf(process.platform === "win32")( + "resolves Windows DPAPI PowerShell through kernel SystemRoot despite poisoned environment paths", + () => { + const previousSystemRoot = process.env.SystemRoot; + const previousWinDir = process.env.windir; + process.env.SystemRoot = path.join(tempDir, "attacker-system-root"); + process.env.windir = path.join(tempDir, "attacker-windir"); + try { + const resolved = resolveWindowsDpapiPowerShellPath(); + expect(path.win32.isAbsolute(resolved)).toBe(true); + expect(resolved.toLowerCase()).toMatch( + /\\system32\\windowspowershell\\v1\.0\\powershell\.exe$/, + ); + expect(resolved.toLowerCase()).not.toContain(tempDir.toLowerCase()); + } finally { + if (previousSystemRoot === undefined) delete process.env.SystemRoot; + else process.env.SystemRoot = previousSystemRoot; + if (previousWinDir === undefined) delete process.env.windir; + else process.env.windir = previousWinDir; + } + }, + ); + + it.runIf(process.platform === "win32")( + "binds headless credential encryption to the current Windows account with DPAPI", + async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + try { + const syncDir = path.join(tempDir, "sync-dpapi"); + const syncMaterial = readOrCreateWindowsDpapiMaterial(syncDir); + const protectedKeyPath = path.join(syncDir, ".credential-key.dpapi"); + const protectedKey = fs.readFileSync(protectedKeyPath, "utf8"); + + expect(syncMaterial).toHaveLength(32); + expect(protectedKey).toContain("ADE_WINDOWS_DPAPI_KEY_V1"); + expect(protectedKey).not.toContain(syncMaterial.toString("base64")); + expect(readOrCreateWindowsDpapiMaterial(syncDir)).toEqual(syncMaterial); + + const store = new EncryptedFileCredentialStore({ secretsDir: syncDir }); + store.setSync("account.session.v1", "windows-account-session"); + const credentialsPath = path.join(syncDir, "credentials.json.enc"); + const machineKeyPath = path.join(syncDir, ".machine-key"); + expect(fs.readFileSync(credentialsPath, "utf8")) + .not.toContain("windows-account-session"); + + const explicitPathReader = new EncryptedFileCredentialStore({ + credentialsPath, + machineKeyPath, + }); + expect(explicitPathReader.getSync("account.session.v1")) + .toBe("windows-account-session"); + await expect(explicitPathReader.get("account.session.v1")) + .resolves.toBe("windows-account-session"); + + const customCredentialDir = path.join(tempDir, "custom-credential-dir"); + const customKeyDir = path.join(tempDir, "custom-key-dir"); + const customMachineKeyPath = path.join(customKeyDir, ".machine-key"); + const customStore = new EncryptedFileCredentialStore({ + secretsDir: customCredentialDir, + machineKeyPath: customMachineKeyPath, + }); + customStore.setSync("account.session.v1", "custom-key-location"); + expect(fs.existsSync(path.join(customKeyDir, ".credential-key.dpapi"))).toBe(true); + expect(fs.existsSync(path.join(customCredentialDir, ".credential-key.dpapi"))).toBe(false); + expect(new EncryptedFileCredentialStore({ + credentialsPath: path.join(customCredentialDir, "credentials.json.enc"), + machineKeyPath: customMachineKeyPath, + }).getSync("account.session.v1")).toBe("custom-key-location"); + + const asyncDir = path.join(tempDir, "async-dpapi"); + const asyncMaterial = await readOrCreateWindowsDpapiMaterialAsync(asyncDir); + expect(asyncMaterial).toHaveLength(32); + expect(fs.readFileSync(path.join(asyncDir, ".credential-key.dpapi"), "utf8")) + .not.toContain(asyncMaterial.toString("base64")); + } finally { + if (previousNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previousNodeEnv; + if (previousVitest === undefined) delete process.env.VITEST; + else process.env.VITEST = previousVitest; + } + }, + 20_000, + ); + it("persists credentials encrypted on disk", async () => { const store = new EncryptedFileCredentialStore({ secretsDir: tempDir }); @@ -195,6 +287,31 @@ new EncryptedFileCredentialStore({ secretsDir }).setSync(key, value); expect(unbound.getSync("linear.token.v1")).toBeNull(); }); + it("atomically binds legacy Windows ciphertext on the first asynchronous credential read", async () => { + const legacyStore = new EncryptedFileCredentialStore({ + secretsDir: tempDir, + keyMaterialProvider: () => null, + }); + legacyStore.setSync("account.session.v1", "legacy-async-windows-session"); + const credentialsPath = path.join(tempDir, "credentials.json.enc"); + const legacyCiphertext = fs.readFileSync(credentialsPath, "utf8"); + const osMaterial = Buffer.from("windows-async-account-bound-material"); + + const upgraded = new EncryptedFileCredentialStore({ + secretsDir: tempDir, + keyMaterialProvider: () => { + throw new Error("async migration must not use synchronous key access"); + }, + keyMaterialProviderAsync: async () => osMaterial, + }); + await expect(upgraded.get("account.session.v1")).resolves.toBe("legacy-async-windows-session"); + expect(fs.readFileSync(credentialsPath, "utf8")).not.toBe(legacyCiphertext); + expect(new EncryptedFileCredentialStore({ + secretsDir: tempDir, + keyMaterialProvider: () => null, + }).getSync("account.session.v1")).toBeNull(); + }); + it("uses the asynchronous key-material path for asynchronous reads", async () => { const osMaterial = Buffer.from("test-os-material"); new EncryptedFileCredentialStore({ @@ -251,23 +368,21 @@ new EncryptedFileCredentialStore({ secretsDir }).setSync(key, value); expect(asyncProvider).not.toHaveBeenCalled(); }); - it("can read legacy machine-key ciphertext before rewriting with OS-bound key material", async () => { + it("atomically binds legacy Windows ciphertext on the first synchronous credential read", () => { const legacy = new EncryptedFileCredentialStore({ secretsDir: tempDir, keyMaterialProvider: () => null, }); legacy.setSync("agent.token", "legacy_secret"); + const credentialPath = path.join(tempDir, "credentials.json.enc"); + const legacyCiphertext = fs.readFileSync(credentialPath, "utf8"); const upgraded = new EncryptedFileCredentialStore({ secretsDir: tempDir, keyMaterialProvider: () => Buffer.from("test-os-material"), }); expect(upgraded.getSync("agent.token")).toBe("legacy_secret"); - expect(legacy.getSync("agent.token")).toBe("legacy_secret"); - - upgraded.setSync("agent.token", "bound_secret"); - - expect(upgraded.getSync("agent.token")).toBe("bound_secret"); + expect(fs.readFileSync(credentialPath, "utf8")).not.toBe(legacyCiphertext); expect(legacy.getSync("agent.token")).toBeNull(); }); diff --git a/apps/ade-cli/src/services/credentials/credentialStore.ts b/apps/ade-cli/src/services/credentials/credentialStore.ts index 1f7962a2d..73329399d 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.ts @@ -3,6 +3,10 @@ import { execFile, execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { resolveMachineAdeLayout } from "../projects/machineLayout"; +import { + readOrCreateWindowsDpapiMaterial, + readOrCreateWindowsDpapiMaterialAsync, +} from "./windowsDpapiMaterial"; export interface CredentialStore { get(key: string): Promise; @@ -57,6 +61,10 @@ const CREDENTIAL_CHANGE_POLL_INTERVAL_MS = 250; const MACOS_KEYCHAIN_READ_TIMEOUT_MS = 2_000; const MACOS_KEYCHAIN_NEGATIVE_CACHE_MS = 30_000; let cachedDefaultOsBoundKeyMaterial: Buffer | null = null; +// Keyed by resolved secrets directory: DPAPI material is protected per +// directory, so unlike the single macOS keychain item these cannot share a slot. +const windowsDpapiMaterialCache = new Map(); +const windowsDpapiReadInFlight = new Map>(); let defaultOsBoundKeyMaterialReadInFlight: Promise | null = null; let lastMissingDefaultOsBoundKeyMaterialAt = 0; @@ -114,6 +122,26 @@ function isEexist(error: unknown): boolean { && (error as { code?: unknown }).code === "EEXIST"; } +/** + * Windows does not report lock contention as EEXIST the way POSIX does. + * + * Deleting a file on Windows only unlinks the name once every open handle to it + * closes, so between one holder's unlink and the last handle drop the lock name + * still occupies the directory in a "delete pending" state. A concurrent + * `open(lockPath, "wx")` against that name fails with a delete-pending or + * sharing violation, which Node surfaces as EPERM, EACCES or EBUSY instead of + * EEXIST. Those are the same "someone else holds it, try again" condition, so + * they have to keep the acquisition loop running; treating them as fatal makes + * every concurrent credential write a coin flip on Windows. + */ +function isLockContention(error: unknown): boolean { + if (isEexist(error)) return true; + if (process.platform !== "win32") return false; + if (typeof error !== "object" || error === null || !("code" in error)) return false; + const code = (error as { code?: unknown }).code; + return code === "EPERM" || code === "EACCES" || code === "EBUSY"; +} + function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } @@ -280,10 +308,10 @@ function withCredentialFileLock(lockPath: string, fn: () => T): T { } ensureMode600(lockPath); } catch (error: unknown) { - if (!isEexist(error)) throw error; + if (!isLockContention(error)) throw error; removeStaleLock(lockPath); if (Date.now() >= deadline) { - throw new Error("Timed out waiting for ADE credential store lock."); + throw new Error("Timed out waiting for ADE credential store lock.", { cause: error }); } sleepSync(LOCK_RETRY_MS); } @@ -579,11 +607,28 @@ async function readMacKeychainMaterialAsync(): Promise { }); } -function readDefaultOsBoundKeyMaterial(): Buffer | null { +function readDefaultOsBoundKeyMaterial(secretsDir: string): Buffer | null { const envMaterial = readCredentialPassphraseFromEnv(); if (envMaterial) return envMaterial; if (process.env.ADE_CREDENTIAL_STORE_DISABLE_OS_BINDING === "1") return null; if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") return null; + if (process.platform === "win32") { + // Windows re-spawned `powershell.exe` on every credential read, where macOS + // spawns `security` once and caches. That is a far worse trade than it + // looks: PowerShell 5.1 pays CLR load, System.Security from disk, and + // Defender's on-access scan each time. + // + // The cache must be keyed by directory, unlike macOS. Keychain material is + // one global item, but DPAPI material is protected per secrets directory + // (`/.credential-key.dpapi`), so a single shared slot would + // hand one store another store's key. + const key = path.resolve(secretsDir); + const cached = windowsDpapiMaterialCache.get(key); + if (cached) return cached; + const material = readOrCreateWindowsDpapiMaterial(secretsDir); + if (material) windowsDpapiMaterialCache.set(key, material); + return material; + } if (cachedDefaultOsBoundKeyMaterial) return cachedDefaultOsBoundKeyMaterial; const material = readOrCreateMacKeychainMaterial(); if (material) { @@ -593,11 +638,36 @@ function readDefaultOsBoundKeyMaterial(): Buffer | null { return material; } -async function readDefaultOsBoundKeyMaterialAsync(): Promise { +async function readDefaultOsBoundKeyMaterialAsync(secretsDir: string): Promise { const envMaterial = readCredentialPassphraseFromEnv(); if (envMaterial) return envMaterial; if (process.env.ADE_CREDENTIAL_STORE_DISABLE_OS_BINDING === "1") return null; if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") return null; + if (process.platform === "win32") { + const key = path.resolve(secretsDir); + const cached = windowsDpapiMaterialCache.get(key); + if (cached) return cached; + // In-flight dedup matters more here than it ever did on macOS: without it, + // concurrent credential reads each spawn their own PowerShell, and that + // contention is what makes a cold start slow enough to hit the timeout. + // No negative cache -- a locked keychain is a durable state worth backing + // off from, but a DPAPI failure is usually a transient timeout, and + // suppressing retries would make one slow cold start look permanent. + const pending = windowsDpapiReadInFlight.get(key); + if (pending) return await pending; + const inFlight = readOrCreateWindowsDpapiMaterialAsync(secretsDir).then((material) => { + if (material) windowsDpapiMaterialCache.set(key, material); + return material; + }); + windowsDpapiReadInFlight.set(key, inFlight); + try { + return await inFlight; + } finally { + if (windowsDpapiReadInFlight.get(key) === inFlight) { + windowsDpapiReadInFlight.delete(key); + } + } + } if (cachedDefaultOsBoundKeyMaterial) return cachedDefaultOsBoundKeyMaterial; if ( lastMissingDefaultOsBoundKeyMaterialAt > 0 @@ -655,12 +725,14 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { const secretsDir = args.secretsDir ?? resolveMachineAdeLayout().secretsDir; this.credentialsPath = args.credentialsPath ?? path.join(secretsDir, DEFAULT_CREDENTIALS_FILE); this.machineKeyPath = args.machineKeyPath ?? path.join(secretsDir, DEFAULT_MACHINE_KEY_FILE); + const osBindingDir = path.dirname(this.machineKeyPath); this.lockPath = args.lockPath ?? defaultLockPath(this.credentialsPath); - this.keyMaterialProvider = args.keyMaterialProvider ?? readDefaultOsBoundKeyMaterial; + this.keyMaterialProvider = args.keyMaterialProvider + ?? (() => readDefaultOsBoundKeyMaterial(osBindingDir)); this.keyMaterialProviderAsync = args.keyMaterialProviderAsync ?? (args.keyMaterialProvider ? async () => args.keyMaterialProvider?.() ?? null - : readDefaultOsBoundKeyMaterialAsync); + : () => readDefaultOsBoundKeyMaterialAsync(osBindingDir)); this.credentialChangePollIntervalMs = args.credentialChangePollIntervalMs === undefined ? CREDENTIAL_CHANGE_POLL_INTERVAL_MS : args.credentialChangePollIntervalMs; @@ -687,7 +759,9 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { getSync(key: string): string | null { const normalized = normalizeKey(key); - return this.readAll({ allowRewrite: false })[normalized] ?? null; + return this.withLock( + () => this.readAll({ allowRewrite: false, migrateLegacy: true })[normalized] ?? null, + ); } getLastReadState(): CredentialStoreReadState { @@ -754,7 +828,7 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { return this.readAll({ allowRewrite: false }); } - private readAll(args: { allowRewrite: boolean }): Record { + private readAll(args: { allowRewrite: boolean; migrateLegacy?: boolean }): Record { const credentialsExist = fs.existsSync(this.credentialsPath); const raw = readJsonObject(this.credentialsPath); const machineKey = readOrCreateMachineKey(this.machineKeyPath); @@ -777,12 +851,8 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { throw error; } this.lastReadState = credentialsExist ? "available" : "missing"; - if (args.allowRewrite) { - try { - this.writeAll(values); - } catch { - // Preserve read compatibility if migration cannot rewrite right now. - } + if (args.allowRewrite || args.migrateLegacy) { + this.writeAllWithKey(values, key); } return values; } @@ -811,7 +881,8 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { throw new Error("Unsupported ADE credential store format."); } const machineKey = await readOrCreateMachineKeyAsync(this.machineKeyPath); - const key = deriveOsBoundCredentialKey(machineKey, await this.keyMaterialProviderAsync()); + const osMaterial = await this.keyMaterialProviderAsync(); + const key = deriveOsBoundCredentialKey(machineKey, osMaterial); if (!key.equals(machineKey)) { try { const values = deserializeStore(raw, key, { emptyOnDecryptFailure: false }); @@ -819,7 +890,16 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { return values; } catch { try { - const values = deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false }); + deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false }); + } catch (error) { + this.lastReadState = "unreadable"; + throw error; + } + try { + if (!osMaterial || osMaterial.length === 0) { + throw new Error("OS-bound credential material is unavailable during migration."); + } + const values = this.withLock(() => this.migrateLegacyUnderLock(osMaterial)); this.lastReadState = "available"; return values; } catch (error) { @@ -841,9 +921,26 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { private writeAll(values: Record): void { const machineKey = readOrCreateMachineKey(this.machineKeyPath); const key = deriveOsBoundCredentialKey(machineKey, this.keyMaterialProvider()); + this.writeAllWithKey(values, key); + } + + private writeAllWithKey(values: Record, key: Buffer): void { writeFileAtomic(this.credentialsPath, `${JSON.stringify(serializeStore(values, key), null, 2)}\n`); } + private migrateLegacyUnderLock(osMaterial: Buffer): Record { + const raw = readJsonObject(this.credentialsPath); + const machineKey = readOrCreateMachineKey(this.machineKeyPath); + const key = deriveOsBoundCredentialKey(machineKey, osMaterial); + try { + return deserializeStore(raw, key, { emptyOnDecryptFailure: false }); + } catch { + const values = deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false }); + this.writeAllWithKey(values, key); + return values; + } + } + private withLock(fn: () => T): T { return withCredentialFileLock(this.lockPath, fn); } diff --git a/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts b/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts new file mode 100644 index 000000000..83e760406 --- /dev/null +++ b/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts @@ -0,0 +1,308 @@ +import crypto from "node:crypto"; +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const WINDOWS_DPAPI_KEY_FILE = ".credential-key.dpapi"; +const WINDOWS_DPAPI_KEY_MAGIC = "ADE_WINDOWS_DPAPI_KEY_V1"; +/** + * DPAPI itself is a local, sub-millisecond call; essentially the whole budget + * pays for a Windows PowerShell 5.1 cold start. That start is not bounded by + * anything ADE controls - it loads the CLR and the System.Security assembly + * from disk, and Defender's on-access scanner inspects powershell.exe and each + * assembly the first time they are touched. On a contended machine (a CI + * runner, or a laptop right after login) it routinely runs several seconds, + * which a 5s budget turned into a hard "credentials are unavailable" failure + * for a helper that had done nothing wrong. Bound the helper generously + * instead: waiting longer only costs time in the case that was already broken, + * while a tight bound costs the user their credentials. + */ +const WINDOWS_DPAPI_TIMEOUT_MS = 30_000; +const WINDOWS_DPAPI_MAX_OUTPUT_BYTES = 64 * 1024; +const WINDOWS_DPAPI_POWERSHELL_KERNEL_PATH = + "\\\\?\\GLOBALROOT\\SystemRoot\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + +const cachedKeyMaterial = new Map(); +const keyMaterialReadInFlight = new Map>(); + +const WINDOWS_DPAPI_SCRIPT = [ + "$ErrorActionPreference = 'Stop'", + "Add-Type -AssemblyName System.Security", + "$inputBytes = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim())", + "$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser", + "if ($env:ADE_DPAPI_OPERATION -eq 'protect') {", + " $outputBytes = [Security.Cryptography.ProtectedData]::Protect($inputBytes, $null, $scope)", + "} elseif ($env:ADE_DPAPI_OPERATION -eq 'unprotect') {", + " $outputBytes = [Security.Cryptography.ProtectedData]::Unprotect($inputBytes, $null, $scope)", + "} else {", + " throw 'Unknown DPAPI operation.'", + "}", + "[Console]::Out.Write([Convert]::ToBase64String($outputBytes))", +].join("; "); + +function isNodeErrorCode(error: unknown, code: string): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === code; +} + +function ensureDirectory(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); +} + +function parseProtectedKeyFile(raw: string): Buffer { + const [magic, encoded, ...rest] = raw.trim().split(/\r?\n/); + if (magic !== WINDOWS_DPAPI_KEY_MAGIC || !encoded || rest.length > 0) { + throw new Error("ADE Windows credential key has an unsupported format."); + } + const protectedKey = Buffer.from(encoded, "base64"); + if (protectedKey.length === 0) { + throw new Error("ADE Windows credential key is invalid."); + } + return protectedKey; +} + +function decodeDpapiResult(raw: string): Buffer { + const value = raw.trim(); + const decoded = value ? Buffer.from(value, "base64") : Buffer.alloc(0); + if (decoded.length === 0) { + throw new Error("Windows DPAPI returned an empty credential key."); + } + return decoded; +} + +function dpapiChildEnv(operation: "protect" | "unprotect"): NodeJS.ProcessEnv { + const allowed = new Set([ + "comspec", + "path", + "pathext", + "psmodulepath", + "systemroot", + "temp", + "tmp", + "windir", + ]); + const env: NodeJS.ProcessEnv = { ADE_DPAPI_OPERATION: operation }; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && allowed.has(key.toLowerCase())) env[key] = value; + } + return env; +} + +function dpapiArguments(): string[] { + return [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + WINDOWS_DPAPI_SCRIPT, + ]; +} + +/** + * Resolve Windows PowerShell through the kernel-owned SystemRoot link. The + * mutable SystemRoot/windir environment and CreateProcess executable search + * are intentionally not involved, so an untrusted project or poisoned launch + * environment cannot redirect the DPAPI helper. + */ +export function resolveWindowsDpapiPowerShellPath(): string { + try { + const resolved = path.win32.normalize( + fs.realpathSync.native(WINDOWS_DPAPI_POWERSHELL_KERNEL_PATH), + ); + const parsed = path.win32.parse(resolved); + const expectedSuffix = "\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + if ( + !path.win32.isAbsolute(resolved) + || !/^[A-Za-z]:\\$/.test(parsed.root) + || !resolved.toLowerCase().endsWith(expectedSuffix.toLowerCase()) + || !fs.statSync(resolved).isFile() + ) { + throw new Error("invalid system PowerShell path"); + } + return resolved; + } catch { + throw new Error("Windows DPAPI credential protection is unavailable."); + } +} + +function runDpapiSync(operation: "protect" | "unprotect", value: Buffer): Buffer { + const result = spawnSync(resolveWindowsDpapiPowerShellPath(), dpapiArguments(), { + encoding: "utf8", + env: dpapiChildEnv(operation), + input: value.toString("base64"), + maxBuffer: WINDOWS_DPAPI_MAX_OUTPUT_BYTES, + timeout: WINDOWS_DPAPI_TIMEOUT_MS, + windowsHide: true, + }); + if (result.error) { + // spawnSync folds "could not start" and "ran past the deadline" into the + // same field. They are different diagnoses - one means the helper is + // missing or blocked, the other means the machine was busy - and the async + // path already reports them apart. + if (isNodeErrorCode(result.error, "ETIMEDOUT")) { + throw new Error("Windows DPAPI credential protection timed out."); + } + throw new Error("Windows DPAPI credential protection is unavailable."); + } + if (result.status !== 0) { + throw new Error("Windows DPAPI credential protection failed."); + } + return decodeDpapiResult(result.stdout ?? ""); +} + +function runDpapiAsync(operation: "protect" | "unprotect", value: Buffer): Promise { + return new Promise((resolve, reject) => { + const child = spawn(resolveWindowsDpapiPowerShellPath(), dpapiArguments(), { + stdio: ["pipe", "pipe", "pipe"], + env: dpapiChildEnv(operation), + windowsHide: true, + }); + const stdout: Buffer[] = []; + let stdoutBytes = 0; + let settled = false; + const finish = (error: Error | null, output?: Buffer): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) reject(error); + else resolve(output ?? Buffer.alloc(0)); + }; + const timeout = setTimeout(() => { + child.kill(); + finish(new Error("Windows DPAPI credential protection timed out.")); + }, WINDOWS_DPAPI_TIMEOUT_MS); + timeout.unref?.(); + child.once("error", () => { + finish(new Error("Windows DPAPI credential protection is unavailable.")); + }); + child.stdout.on("data", (chunk: Buffer | string) => { + const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stdoutBytes += next.length; + if (stdoutBytes > WINDOWS_DPAPI_MAX_OUTPUT_BYTES) { + child.kill(); + finish(new Error("Windows DPAPI credential protection returned too much data.")); + return; + } + stdout.push(next); + }); + // Drain stderr without retaining it. PowerShell errors can contain host + // details, and diagnostics never need the protected key or credential input. + child.stderr.resume(); + child.stdin.once("error", () => { + finish(new Error("Windows DPAPI credential protection input failed.")); + }); + child.once("close", (code) => { + if (settled) return; + if (code !== 0) { + finish(new Error("Windows DPAPI credential protection failed.")); + return; + } + try { + finish(null, decodeDpapiResult(Buffer.concat(stdout).toString("utf8"))); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }); + child.stdin.end(value.toString("base64")); + }); +} + +function protectedKeyPath(secretsDir: string): string { + return path.resolve(secretsDir, WINDOWS_DPAPI_KEY_FILE); +} + +function unprotectKey(keyPath: string): Buffer { + const material = runDpapiSync( + "unprotect", + parseProtectedKeyFile(fs.readFileSync(keyPath, "utf8")), + ); + if (material.length !== 32) throw new Error("ADE Windows credential key is invalid."); + return material; +} + +async function unprotectKeyAsync(keyPath: string): Promise { + const material = await runDpapiAsync( + "unprotect", + parseProtectedKeyFile(await fs.promises.readFile(keyPath, "utf8")), + ); + if (material.length !== 32) throw new Error("ADE Windows credential key is invalid."); + return material; +} + +/** + * Returns a per-user, per-ADE-home key protected by Windows DPAPI. The random + * key crosses the PowerShell boundary only on stdin/stdout and the persisted + * blob is unusable from another Windows account. + */ +export function readOrCreateWindowsDpapiMaterial(secretsDir: string): Buffer { + const keyPath = protectedKeyPath(secretsDir); + const cached = cachedKeyMaterial.get(keyPath); + if (cached) return cached; + + let material: Buffer; + try { + material = unprotectKey(keyPath); + } catch (error) { + if (!isNodeErrorCode(error, "ENOENT")) throw error; + material = crypto.randomBytes(32); + const protectedKey = runDpapiSync("protect", material); + ensureDirectory(path.dirname(keyPath)); + try { + fs.writeFileSync( + keyPath, + `${WINDOWS_DPAPI_KEY_MAGIC}\n${protectedKey.toString("base64")}\n`, + { flag: "wx", mode: 0o600 }, + ); + } catch (writeError) { + if (!isNodeErrorCode(writeError, "EEXIST")) throw writeError; + material = unprotectKey(keyPath); + } + } + cachedKeyMaterial.set(keyPath, material); + return material; +} + +/** Async counterpart used by brain-facing credential reads. */ +export async function readOrCreateWindowsDpapiMaterialAsync(secretsDir: string): Promise { + const keyPath = protectedKeyPath(secretsDir); + const cached = cachedKeyMaterial.get(keyPath); + if (cached) return cached; + const existing = keyMaterialReadInFlight.get(keyPath); + if (existing) return await existing; + + const read = (async () => { + let material: Buffer; + try { + material = await unprotectKeyAsync(keyPath); + } catch (error) { + if (!isNodeErrorCode(error, "ENOENT")) throw error; + material = crypto.randomBytes(32); + const protectedKey = await runDpapiAsync("protect", material); + await fs.promises.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 }); + try { + await fs.promises.writeFile( + keyPath, + `${WINDOWS_DPAPI_KEY_MAGIC}\n${protectedKey.toString("base64")}\n`, + { flag: "wx", mode: 0o600 }, + ); + } catch (writeError) { + if (!isNodeErrorCode(writeError, "EEXIST")) throw writeError; + material = await unprotectKeyAsync(keyPath); + } + } + cachedKeyMaterial.set(keyPath, material); + return material; + })(); + keyMaterialReadInFlight.set(keyPath, read); + try { + return await read; + } finally { + if (keyMaterialReadInFlight.get(keyPath) === read) { + keyMaterialReadInFlight.delete(keyPath); + } + } +} diff --git a/apps/ade-cli/src/services/runtime/socketSpawnLock.ts b/apps/ade-cli/src/services/runtime/socketSpawnLock.ts index 4db2ab6dc..790701917 100644 --- a/apps/ade-cli/src/services/runtime/socketSpawnLock.ts +++ b/apps/ade-cli/src/services/runtime/socketSpawnLock.ts @@ -66,6 +66,19 @@ function processExists(pid: number): boolean { } } +// A contended `open(..., "wx")` reports EEXIST on POSIX, but Windows keeps a +// deleted name in the directory until the last handle closes. Between the +// holder's unlink and that final drop, a contending open hits the +// delete-pending name and Node surfaces EPERM, EACCES or EBUSY instead. Those +// are contention, not failure, so the caller must wait rather than abort -- +// this is the brain-spawn path, where burst contention is the normal case. +function isSocketSpawnLockContention(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code === "EEXIST") return true; + if (process.platform !== "win32") return false; + return code === "EPERM" || code === "EACCES" || code === "EBUSY"; +} + function unlinkSocketSpawnLockIfStale(lockPath: string): boolean { try { const stat = fs.statSync(lockPath); @@ -134,11 +147,10 @@ export async function withSocketSpawnLock(socketPath: string, task: () => Pro fs.writeFileSync(fd, serializeSocketSpawnLockOwner(owner), "utf8"); break; } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "EEXIST") throw error; + if (!isSocketSpawnLockContention(error)) throw error; if (unlinkSocketSpawnLockIfStale(lockPath)) continue; if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for ADE socket spawn lock at ${lockPath}.`); + throw new Error(`Timed out waiting for ADE socket spawn lock at ${lockPath}.`, { cause: error }); } await new Promise((resolve) => setTimeout(resolve, 100)); } diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts index 741481e84..8e70e2b61 100644 --- a/apps/ade-cli/src/stdioRpcDaemon.test.ts +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; type JsonRpcResponse = { id?: number; @@ -30,6 +31,35 @@ function fileSha256(filePath: string): string { return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); } +/** + * Resolve the machine runtime endpoint for a temp `ADE_HOME` exactly the way + * production does (`resolveMachineRuntimeSocketPath` in cli.ts falls through to + * this same layout), so daemon-backed tests exercise the real per-platform + * transport: a Unix domain socket at `/sock/ade.sock` on macOS and + * Linux, and a per-user named pipe on Windows. + * + * Hardcoding `/sock/ade.sock` is not portable. Windows has no Unix + * domain sockets, so `net` treats a path-style endpoint as a named pipe name + * there; a filesystem path is not a connectable address and every such test + * died with `connect ENOENT`. Deriving the endpoint keeps the POSIX value + * byte-identical — `resolveMachineAdeLayout` returns exactly + * `path.join(adeHome, "sock", "ade.sock")` off win32 — while giving Windows the + * address the runtime actually listens on. + */ +function machineRuntimeSocketPath(adeHome: string): string { + return resolveMachineAdeLayout({ ...process.env, ADE_HOME: adeHome }).socketPath; +} + +/** + * Mirrors `LOCAL_RUNTIME_STARTUP_TIMEOUT_MS` in the desktop local runtime pool: + * a cold Windows daemon start (process spawn + tsx transform + SQLite init) is + * genuinely slower than on macOS/Linux, so production already waits 30s there + * against 10s elsewhere. This is a ceiling, not a sleep — a healthy daemon is + * reachable in a few seconds on every platform — so widening it on Windows only + * removes a false failure under load. + */ +const RUNTIME_SOCKET_READY_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 10_000; + async function getFreeTcpPort(): Promise { const server = net.createServer(); await new Promise((resolve, reject) => { @@ -55,7 +85,7 @@ async function getFreeTcpPort(): Promise { async function waitForConnection( label: string, connect: () => net.Socket, - timeoutMs = 10_000, + timeoutMs = RUNTIME_SOCKET_READY_TIMEOUT_MS, ): Promise { const startedAt = Date.now(); let lastError: Error | null = null; @@ -87,11 +117,17 @@ async function waitForConnection( throw lastError ?? new Error(`ADE runtime socket did not become available: ${label}`); } -async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise { +async function waitForSocket( + socketPath: string, + timeoutMs = RUNTIME_SOCKET_READY_TIMEOUT_MS, +): Promise { await waitForConnection(socketPath, () => net.createConnection(socketPath), timeoutMs); } -async function waitForTcpUrl(tcpUrl: string, timeoutMs = 10_000): Promise { +async function waitForTcpUrl( + tcpUrl: string, + timeoutMs = RUNTIME_SOCKET_READY_TIMEOUT_MS, +): Promise { const parsed = new URL(tcpUrl); const port = Number.parseInt(parsed.port, 10); const host = parsed.hostname; @@ -225,10 +261,8 @@ class StdioRpcProcess { } } -const itUnix = process.platform === "win32" ? it.skip : it; - describe("ade rpc --stdio daemon bridge", () => { - itUnix("keeps the machine runtime alive after the stdio client exits", async () => { + it("keeps the machine runtime alive after the stdio client exits", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-")); @@ -236,7 +270,7 @@ describe("ade rpc --stdio daemon bridge", () => { fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-project-")), ); const expectedProjectRoot = projectRoot; - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const env = { ...process.env, ADE_HOME: adeHome, @@ -290,11 +324,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("restarts a stale daemon before bridging stdio requests", async () => { + it("restarts a stale daemon before bridging stdio requests", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-version-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const baseEnv = { ...process.env, ADE_HOME: adeHome, @@ -345,11 +379,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("restarts a same-version daemon when its build hash is stale", async () => { + it("restarts a same-version daemon when its build hash is stale", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-build-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const baseEnv = { ...process.env, ADE_HOME: adeHome, @@ -404,11 +438,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("accepts a compatible TCP daemon and computes a build hash when none is advertised", async () => { + it("accepts a compatible TCP daemon and computes a build hash when none is advertised", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-tcp-build-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const tcpPort = await getFreeTcpPort(); const tcpUrl = `tcp://127.0.0.1:${tcpPort}`; const baseEnv = { @@ -466,11 +500,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("keeps a compatible cto daemon when the proxy requests an agent role", async () => { + it("keeps a compatible cto daemon when the proxy requests an agent role", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-role-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const baseEnv = { ...process.env, ADE_HOME: adeHome, @@ -525,11 +559,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("does not replace a real daemon when the bridging CLI has only the placeholder version", async () => { + it("does not replace a real daemon when the bridging CLI has only the placeholder version", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-placeholder-version-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const baseEnv = { ...process.env, ADE_HOME: adeHome, @@ -579,11 +613,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("restarts an incompatible-role daemon even when the proxy has only the placeholder version", async () => { + it("restarts an incompatible-role daemon even when the proxy has only the placeholder version", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-placeholder-role-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const baseEnv = { ...process.env, ADE_HOME: adeHome, diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index 916b4d7a7..7b256517a 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -14,6 +14,11 @@ import { import { JsonRpcClient } from "../jsonRpcClient"; import { startTuiHeartbeat, type TuiHeartbeat } from "../heartbeat"; import { ProcessJsonRpcClient } from "../remoteBridge"; +import { + socketSpawnLockPath, + withSocketSpawnLock, +} from "../../services/runtime/socketSpawnLock"; +import { resolveMachineAdeLayout } from "../../services/projects/machineLayout"; import { appendDedupedTuiEvent, appendReservedTuiEvent, @@ -123,7 +128,15 @@ function useMissingMachineSocket(): string { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-machine-")); process.env.ADE_HOME = adeHome; delete process.env.ADE_RPC_SOCKET_PATH; - return path.join(adeHome, "sock", "ade.sock"); + return resolveMachineAdeLayout().socketPath; +} + +let nextTestPipeId = 1; + +function localTestSocketPath(tmpDir: string, fileName: string): string { + if (process.platform !== "win32") return path.join(tmpDir, fileName); + const stem = fileName.replace(/[^a-zA-Z0-9_-]+/g, "-"); + return `\\\\.\\pipe\\ade-code-${process.pid}-${nextTestPipeId++}-${stem}`; } function mockAttachedClient(): { @@ -255,7 +268,7 @@ describe("connectToAde embedded mode", () => { it("does not silently fall back to embedded mode when socket attach fails", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-missing-socket-")); - const socketPath = path.join(tmpDir, "missing.sock"); + const socketPath = localTestSocketPath(tmpDir, "missing.sock"); await expect(connectToAde({ project, @@ -267,7 +280,7 @@ describe("connectToAde embedded mode", () => { it("explains remote bridge failures without exposing its temporary socket path", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-remote-bridge-")); - const socketPath = path.join(tmpDir, "bridge.sock"); + const socketPath = localTestSocketPath(tmpDir, "bridge.sock"); try { await expect(connectToAde({ @@ -298,7 +311,7 @@ describe("connectToAde embedded mode", () => { it("rejects a direct socket whose runtime role is stale", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-stale-role-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: string[] = []; const server = net.createServer((socket) => { let buffer = ""; @@ -333,7 +346,7 @@ describe("connectToAde embedded mode", () => { it("allows remote sockets to differ by build hash and project root", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-remote-socket-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: string[] = []; const server = net.createServer((socket) => { let buffer = ""; @@ -377,7 +390,7 @@ describe("connectToAde embedded mode", () => { it("registers the project and injects projectId when attached to the machine daemon", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: Array<{ method: string; params?: Record }> = []; const server = net.createServer((socket) => { let buffer = ""; @@ -455,7 +468,7 @@ describe("connectToAde embedded mode", () => { it("promotes the project to a recent catalog row for an interactive launch", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: Array<{ method: string; params?: Record }> = []; const server = net.createServer((socket) => { let buffer = ""; @@ -512,7 +525,7 @@ describe("connectToAde embedded mode", () => { it("adapts multi-project runtime chat events into the TUI chat stream", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const serverSocketRef: { current: net.Socket | null } = { current: null }; const requests: Array<{ method: string; params?: Record }> = []; const server = net.createServer((socket) => { @@ -599,7 +612,7 @@ describe("connectToAde embedded mode", () => { it("surfaces runtime event replay gaps to subscribers", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-gap-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const server = net.createServer((socket) => { let buffer = ""; socket.on("error", () => {}); @@ -706,7 +719,7 @@ describe("connectToAde embedded mode", () => { expect(client.close).toHaveBeenCalledTimes(1); }); - it("rechecks the machine socket after taking the spawn lock", async () => { + it.skipIf(process.platform === "win32")("rechecks the machine socket after taking the spawn lock", async () => { const socketPath = useMissingMachineSocket(); const lockPath = path.join(path.dirname(socketPath), `${path.basename(socketPath)}.spawn.lock`); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); @@ -730,6 +743,23 @@ describe("connectToAde embedded mode", () => { expect(fs.existsSync(lockPath)).toBe(false); }); + it("keeps Windows named-pipe spawn locks in the per-user ADE runtime directory", async () => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-pipe-lock-")); + process.env.ADE_HOME = adeHome; + const socketPath = `\\\\.\\pipe\\ade-runtime-stable-${process.pid}`; + const lockPath = socketSpawnLockPath(socketPath); + let ran = false; + + await withSocketSpawnLock(socketPath, async () => { + ran = true; + expect(fs.existsSync(lockPath)).toBe(true); + }); + + expect(ran).toBe(true); + expect(path.dirname(lockPath)).toBe(path.join(adeHome, "runtime", "spawn-locks")); + expect(fs.existsSync(lockPath)).toBe(false); + }); + it("does not spawn a second brain while a recently spawned one is still coming up", async () => { // The spawn lock only serializes the first attempt. A brain that has not yet // bound its socket must not attract a rival spawn from the next `ade code`, @@ -765,7 +795,7 @@ describe("connectToAde embedded mode", () => { expect(childProcess.spawn).toHaveBeenCalledTimes(2); }); - it("unlinks stale machine socket files before retrying daemon startup", async () => { + it.skipIf(process.platform === "win32")("unlinks stale machine socket files before retrying daemon startup", async () => { const socketPath = useMissingMachineSocket(); fs.mkdirSync(path.dirname(socketPath), { recursive: true }); fs.writeFileSync(socketPath, ""); @@ -945,7 +975,7 @@ function closeServer(server: net.Server): Promise { describe("JsonRpcClient", () => { it("handles framed notifications before JSONL responses", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -986,7 +1016,7 @@ describe("JsonRpcClient", () => { it("honors byte-based Content-Length framing for unicode payloads", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1025,7 +1055,7 @@ describe("JsonRpcClient", () => { it("matches responses whose ids are echoed as strings", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); const server = net.createServer((socket) => { let buffer = ""; socket.on("data", (chunk) => { @@ -1061,7 +1091,7 @@ describe("JsonRpcClient", () => { it("handles large Content-Length frames split across many chunks", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1102,7 +1132,7 @@ describe("JsonRpcClient", () => { it("fires onClose when the socket drops unexpectedly", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1124,7 +1154,7 @@ describe("JsonRpcClient", () => { it("does not fire onClose on an intentional close()", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); const server = net.createServer(() => {}); await listenRpc(server, socketPath); const client = await JsonRpcClient.connect(socketPath); @@ -1142,7 +1172,7 @@ describe("JsonRpcClient", () => { it("times out pending requests by tearing down the socket", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1170,7 +1200,7 @@ describe("JsonRpcClient", () => { it("fails the connection on parse garbage instead of continuing", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; diff --git a/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts b/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts index c5ae353be..11ca61008 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts @@ -117,16 +117,16 @@ describe("copy ADE deeplink keybinding", () => { describe("clipboard helper dispatches the right OS command", () => { it("uses pbcopy on darwin with the deeplink as stdin", () => { - const calls: Array<{ cmd: string; args: string[]; input: string }> = []; + const calls: Array<{ cmd: string; args: string[]; input: string; windowsHide: boolean | undefined }> = []; const ok = copyToClipboard("ade://lane/abc", { platform: "darwin", spawn: (cmd, args, opts) => { - calls.push({ cmd, args, input: opts.input }); + calls.push({ cmd, args, input: opts.input, windowsHide: opts.windowsHide }); return { status: 0 }; }, }); expect(ok).toBe(true); - expect(calls).toEqual([{ cmd: "pbcopy", args: [], input: "ade://lane/abc" }]); + expect(calls).toEqual([{ cmd: "pbcopy", args: [], input: "ade://lane/abc", windowsHide: true }]); }); it("uses clip on win32", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts b/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts index 6d3945344..c3753202f 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts @@ -82,6 +82,9 @@ describe("startSyncRemoteBridge", () => { connectionLabel: "local network (studio.local:8787)", })), }); + if (process.platform === "win32") { + expect(bridge.socketUrl).toMatch(/^\\\\\.\\pipe\\ade-code-paired-/); + } const socket = bridge.socketUrl.startsWith("tcp://") ? net.connect(Number(new URL(bridge.socketUrl).port), "127.0.0.1") : net.connect(bridge.socketUrl); diff --git a/apps/ade-cli/src/tuiClient/__tests__/state.test.ts b/apps/ade-cli/src/tuiClient/__tests__/state.test.ts index 94463b7e6..7241a30df 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/state.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/state.test.ts @@ -17,25 +17,27 @@ afterEach(() => { describe("ade code persisted state", () => { it("prefers project-scoped lane and chat state over legacy global fallback", () => { + const repoA = path.resolve("/repo-a"); + const repoB = path.resolve("/repo-b"); const state = normalizeAdeCodeState({ lastChatByLane: { main: "legacy-chat" }, lastLaneId: "legacy-lane", lastChatByProjectLane: { - "/repo-a": { main: "repo-a-chat" }, - "/repo-b": { main: "repo-b-chat" }, + [repoA]: { main: "repo-a-chat" }, + [repoB]: { main: "repo-b-chat" }, }, lastLaneByProject: { - "/repo-a": "repo-a-lane", - "/repo-b": "repo-b-lane", + [repoA]: "repo-a-lane", + [repoB]: "repo-b-lane", }, draftKind: "chat", draftKindByProject: { - "/repo-a": "chat", - "/repo-b": "cli", + [repoA]: "chat", + [repoB]: "cli", }, }); - expect(scopedAdeCodeState(state, "/repo-b")).toEqual({ + expect(scopedAdeCodeState(state, repoB)).toEqual({ lastChatByLane: { main: "repo-b-chat" }, lastLaneId: "repo-b-lane", draftKind: "cli", diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index c372666ee..637bc9cd6 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -12658,7 +12658,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return true; } if (!attachment) { - addNotice("No clipboard image was found. On macOS, copy an image or image file path; ADE Code checks pngpaste and pbpaste.", "error"); + const clipboardHint = process.platform === "win32" + ? "On Windows, copy an image or image file path; ADE Code reads the system clipboard through PowerShell." + : process.platform === "darwin" + ? "On macOS, copy an image or image file path; ADE Code checks pngpaste and pbpaste." + : "Copy an image or image file path; ADE Code checks wl-paste and xclip when available."; + addNotice(`No clipboard image was found. ${clipboardHint}`, "error"); return true; } if (activePaneRef.current !== "chat") { @@ -16737,7 +16742,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, : EMPTY_TERMINAL_CHUNKS; if (error && !connection) { - const remoteLabel = project.remoteLabel?.trim() || "the remote Mac"; + const remoteLabel = project.remoteLabel?.trim() || "the remote computer"; return ( @@ -16748,7 +16753,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, {error} {remoteLaunch ? ( - The remote Mac may be restarting; every retry re-evaluates its saved connection paths. + The remote computer may be restarting; every retry re-evaluates its saved connection paths. ) : null} diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index c01e12acc..60d3c5c30 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -880,7 +880,7 @@ export async function connectToAde(args: { const message = errorMessage(error); if (args.requireSocket) { if (args.remote) { - const remoteLabel = args.project.remoteLabel?.trim() || "the remote Mac"; + const remoteLabel = args.project.remoteLabel?.trim() || "the remote computer"; throw new Error( `Remote ADE connection to ${remoteLabel} was interrupted while ADE Code was starting: ` + `${remoteSocketFailureDetail(message, explicitSocketPath)}. ` + diff --git a/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts b/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts index d1a6fe492..383ca6ee8 100644 --- a/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts +++ b/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts @@ -64,7 +64,7 @@ export async function pairedRouteAccountProof(args: { const expectedOwnerUserId = args.credentials.accountOwnerUserId?.trim() ?? ""; if (expectedOwnerUserId && proof.userId.trim() !== expectedOwnerUserId) { throw new PairedRuntimeRelayAuthRequiredError( - "Sign in with the same ADE account as this Mac to connect through Relay. Local network and Tailscale connections still work without an account.", + "Sign in with the same ADE account as this computer to connect through Relay. Local network and Tailscale connections still work without an account.", ); } return { userId: proof.userId.trim(), token: proof.token.trim() }; @@ -78,7 +78,7 @@ export async function assertRelayAccountUnchanged( const currentProof = await getAccountRelayProof().catch(() => null); if (currentProof?.userId.trim() === initialProof.userId) return; throw new PairedRuntimeRelayAuthRequiredError( - "Your ADE account changed before the Relay connection finished. Sign in with the same account as this Mac and try again.", + "Your ADE account changed before the Relay connection finished. Sign in with the same account as this computer and try again.", ); } diff --git a/apps/ade-cli/src/tuiClient/remoteBridge.ts b/apps/ade-cli/src/tuiClient/remoteBridge.ts index 451915357..2500eab12 100644 --- a/apps/ade-cli/src/tuiClient/remoteBridge.ts +++ b/apps/ade-cli/src/tuiClient/remoteBridge.ts @@ -1,8 +1,10 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import net, { type AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; +import { localIpcListenOptions } from "../services/runtime/localIpcListenOptions"; import { RemoteTargetRegistry } from "../../../desktop/src/main/services/remoteRuntime/remoteTargetRegistry"; import type { RemoteRuntimeTarget, @@ -300,7 +302,11 @@ async function startLocalBridgeListener( if (bridgeDir) { try { fs.chmodSync(bridgeDir, 0o700); } catch {} } - const bridgeSocketPath = bridgeDir ? path.join(bridgeDir, "bridge.sock") : null; + const bridgeSocketPath = process.platform === "win32" + ? `\\\\.\\pipe\\${directoryPrefix}${process.pid}-${randomUUID()}` + : bridgeDir + ? path.join(bridgeDir, "bridge.sock") + : null; const server = net.createServer(onConnection); server.maxConnections = 1; const removeFiles = (): void => { @@ -322,7 +328,7 @@ async function startLocalBridgeListener( }; server.once("listening", onListening); server.once("error", onError); - if (bridgeSocketPath) server.listen(bridgeSocketPath); + if (bridgeSocketPath) server.listen(localIpcListenOptions(bridgeSocketPath)); else server.listen(0, "127.0.0.1"); }); } catch (error) { diff --git a/apps/ade-cli/src/tuiClient/remoteLauncher.ts b/apps/ade-cli/src/tuiClient/remoteLauncher.ts index 38c7644a9..2a44b97e3 100644 --- a/apps/ade-cli/src/tuiClient/remoteLauncher.ts +++ b/apps/ade-cli/src/tuiClient/remoteLauncher.ts @@ -252,10 +252,10 @@ export function parseRemoteAdeCodeArgs(argv: string[]): RemoteCliOptions { function printRemoteHelp(): void { process.stdout.write(`ade code remote -Connect ADE Code to a Mac already saved in ADE Connections. +Connect ADE Code to a computer already saved in ADE Connections. Local network and Tailscale connections work without an ADE account. ADE Relay -requires both Macs to be signed in to the same account. Advanced SSH is used +requires both computers to be signed in to the same account. Advanced SSH is used only when you explicitly save an SSH connection. Usage: @@ -1072,8 +1072,8 @@ export async function listRemoteSessions(client: RemoteRpcClientLike, projectId: async function selectTarget(targets: RemoteRuntimeTarget[], query: string | null): Promise { if (!targets.length) { throw new Error( - "No saved Macs yet. In ADE desktop, open Connections and choose Add machine. " + - "You can sign in to find your Macs, pair directly, scan your network, or use advanced SSH setup.", + "No saved computers yet. In ADE desktop, open Connections and choose Add machine. " + + "You can sign in to find your computers, pair directly, scan your network, or use advanced SSH setup.", ); } if (query) { @@ -1087,10 +1087,10 @@ async function selectTarget(targets: RemoteRuntimeTarget[], query: string | null const selectionMode = machineSelectionMode(targets.length, canPrompt()); if (selectionMode === "auto") return targets[0]!; if (selectionMode === "flag-required") { - throw new Error("Choose a Mac: pass --target non-interactively."); + throw new Error("Choose a computer: pass --target non-interactively."); } return await promptInteractiveChoice( - "Choose a Mac", + "Choose a computer", targets, remoteTargetChoiceLabel, ); @@ -1408,7 +1408,7 @@ export async function runAdeCodeRemote( ); if (target.transport !== "paired" && options.routePreference !== "auto") { throw new Error( - `--route ${options.routePreference} applies only to paired Macs. ` + + `--route ${options.routePreference} applies only to paired computers. ` + `${target.name} is configured for advanced SSH.`, ); } diff --git a/apps/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh new file mode 100644 index 000000000..7752b93ed --- /dev/null +++ b/apps/desktop/build/installer.nsh @@ -0,0 +1,76 @@ +!macro customInit + Var /GLOBAL adeHadPreviousInstall + StrCpy $adeHadPreviousInstall "0" + ReadRegStr $R9 HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation" + ${If} $R9 != "" + StrCpy $adeHadPreviousInstall "1" + ${EndIf} +!macroend + +!macro customInstall + DetailPrint "Configuring the ADE terminal command and per-user brain startup..." + StrCpy $2 "stable" + ${If} "${PRODUCT_NAME}" == "ADE Alpha" + StrCpy $2 "alpha" + ${ElseIf} "${PRODUCT_NAME}" == "ADE Beta" + StrCpy $2 "beta" + ${EndIf} + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-install-setup.ps1" -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' + Pop $0 + Pop $1 + ${If} $0 != 0 + DetailPrint "$1" + MessageBox MB_ICONSTOP|MB_OK "ADE could not configure its terminal command or background startup.$\r$\n$\r$\n$1" + ${If} $adeHadPreviousInstall != "1" + DetailPrint "Rolling back the incomplete ADE product installation..." + ExecWait '"$INSTDIR\${UNINSTALL_FILENAME}" /currentuser /S' $3 + ${If} $3 != 0 + DetailPrint "Incomplete product rollback exited with code $3." + ${EndIf} + ${EndIf} + Abort + ${EndIf} + + ; Pre-authorize the LAN sync listener so first run does not raise the Windows + ; Firewall prompt. Windows only accepts firewall rules from an elevated + ; process and this installer is per-user (perMachine/allowElevation are both + ; false), so the script usually reports that it skipped the change instead of + ; making one. Never fatal: a missing firewall rule costs one Windows prompt, + ; it does not break the install. + DetailPrint "Pre-authorizing ADE local network sync in Windows Firewall..." + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-firewall-rules.ps1" -Action install -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' + Pop $0 + Pop $1 + DetailPrint "$1" + ${If} $0 != 0 + DetailPrint "ADE could not pre-authorize local network sync. Windows will ask once when you first use sync on this network." + ${EndIf} +!macroend + +!macro customUnInstall + DetailPrint "Removing the ADE background service and terminal command..." + StrCpy $2 "stable" + ${If} "${PRODUCT_NAME}" == "ADE Alpha" + StrCpy $2 "alpha" + ${ElseIf} "${PRODUCT_NAME}" == "ADE Beta" + StrCpy $2 "beta" + ${EndIf} + + ; Take the inbound allowance back out before the product goes away, so an + ; uninstall never leaves a rule pointing at a deleted executable. Same + ; elevation caveat as install, and same non-fatal handling. + DetailPrint "Removing the ADE local network sync firewall rules..." + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-firewall-rules.ps1" -Action uninstall -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' + Pop $0 + Pop $1 + DetailPrint "$1" + + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\ade-cli\windows-uninstall-cleanup.ps1" -InstallDir "$INSTDIR" -AppExecutableName "${APP_EXECUTABLE_FILENAME}" -PackageChannel "$2"' + Pop $0 + Pop $1 + ${If} $0 != 0 + DetailPrint "$1" + MessageBox MB_ICONSTOP|MB_OK "ADE could not remove its background service or terminal command. Close ADE and try uninstalling again.$\r$\n$\r$\n$1" + Abort + ${EndIf} +!macroend diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6bbdfc886..3b1e9488e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,7 +21,11 @@ "build:notch": "node ./scripts/build-attention-notch.mjs", "test:notch": "swift test --package-path ./native/ADEAttentionNotch", "build:webclient": "vite build --config vite.webclient.config.ts --configLoader runner && node ./scripts/check-webclient-entry.mjs", - "dist:win": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run validate:win:artifacts && npm run build && electron-builder --win --x64 --publish never && npm run validate:win:release", + "dist:win:test": "node ./scripts/run-windows-test-build.mjs", + "dist:win": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run validate:win:artifacts && npm run build && npm run package:win && npm run validate:win:release", + "dist:win:signed": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run validate:win:artifacts && npm run build && npm run package:win:signed && npm run validate:win:release:signed", + "package:win": "node ./scripts/run-electron-builder.mjs --win --x64 --publish never", + "package:win:signed": "node ./scripts/run-electron-builder.mjs --require-signing --win --x64 --publish never", "dist:mac": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build:notch && npm run build && electron-builder --mac --publish never", "dist:mac:dir": "npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build:notch && npm run build && electron-builder --dir --mac --publish never -c.mac.identity=null -c.mac.notarize=false", "dist:mac:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run materialize:runtime-resources && npm run validate:runtime-resources && npm run sign:mac:runtime-archives && npm run materialize:whisper-resources && npm run validate:whisper-resources && npm run build:notch && npm run build && electron-builder --mac --publish never", @@ -40,6 +44,7 @@ "validate:whisper-resources": "node ./scripts/validate-whisper-resources.mjs", "validate:win:artifacts": "node ./scripts/validate-win-artifacts.mjs --mode=preflight", "validate:win:release": "node ./scripts/validate-win-artifacts.mjs --mode=release", + "validate:win:release:signed": "node ./scripts/validate-win-artifacts.mjs --mode=release --require-signed", "release:mac:local": "node ./scripts/release-mac-local.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", @@ -50,6 +55,9 @@ "test:orchestrator-smoke": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts --reporter=verbose", "test:orchestrator-complex-mock": "vitest run src/main/services/orchestrator/orchestratorSmoke.test.ts -t \"complex mock prompt\" --reporter=verbose", "test:chat-model-runtime-audit": "node ./scripts/audit-chat-model-runtime.mjs --mode=dry-run --max-per-provider=2", + "validate:win:proof": "node ./scripts/windows-proof-manifest.mjs validate", + "validate:win:proof-inventory": "node ./scripts/windows-proof-manifest.mjs validate-inventory && node ./scripts/windows-proof-manifest.mjs validate-provenance", + "test:win:release-contract": "node --test ./scripts/windows-release-contract.test.mjs ./scripts/windows-authenticode.test.mjs ./scripts/windows-uninstall-cleanup.test.mjs ./scripts/windows-proof-manifest.test.mjs", "ade:dev": "npm --prefix ../ade-cli run dev -- --project-root ../..", "ade:build": "npm --prefix ../ade-cli run build", "ade:typecheck": "npm --prefix ../ade-cli run typecheck", @@ -179,7 +187,8 @@ "dist/**/*", "electron.cjs", "package.json", - "vendor/**/*" + "vendor/**/*", + "!node_modules/opencode-windows-x64/**" ], "asarUnpack": [ "dist/main/packagedRuntimeSmoke.cjs", @@ -209,10 +218,11 @@ "node_modules/opencode-linux-arm64-musl/**", "node_modules/opencode-linux-x64-musl/**", "node_modules/opencode-windows-arm64/**", - "node_modules/opencode-windows-x64/**", + "node_modules/opencode-windows-x64-baseline/**", "node_modules/@cursor/sdk/**", "node_modules/@cursor/sdk-darwin-arm64/**", "node_modules/@cursor/sdk-darwin-x64/**", + "node_modules/@cursor/sdk-win32-x64/**", "node_modules/sqlite3/**", "vendor/crsqlite/**" ], @@ -272,7 +282,9 @@ "ade-darwin-arm64", "ade-darwin-arm64.native.tar.gz", "ade-darwin-x64", - "ade-darwin-x64.native.tar.gz" + "ade-darwin-x64.native.tar.gz", + "ade-win32-x64.exe", + "ade-win32-x64.native.tar.gz" ] }, { @@ -298,10 +310,6 @@ "!ggml-base.en.bin" ] }, - { - "from": "resources/app-update.yml", - "to": "app-update.yml" - }, { "from": "../../NOTICE", "to": "NOTICE" @@ -332,7 +340,43 @@ ], "rfc3161TimeStampServer": "http://timestamp.digicert.com" }, - "artifactName": "${productName}-${version}-win-${arch}.${ext}" + "artifactName": "ADE-${version}-win-${arch}.${ext}", + "extraResources": [ + { + "from": "scripts/windows-install-setup.ps1", + "to": "ade-cli/windows-install-setup.ps1" + }, + { + "from": "scripts/windows-uninstall-cleanup.ps1", + "to": "ade-cli/windows-uninstall-cleanup.ps1" + }, + { + "from": "scripts/windows-firewall-rules.ps1", + "to": "ade-cli/windows-firewall-rules.ps1" + }, + { + "from": "resources/runtime", + "to": "runtime", + "filter": [ + "ade-linux-arm64", + "ade-linux-arm64.native.tar.gz", + "ade-linux-x64", + "ade-linux-x64.native.tar.gz" + ] + } + ] + }, + "nsis": { + "include": "build/installer.nsh", + "uninstallDisplayName": "${productName}", + "oneClick": false, + "perMachine": false, + "allowElevation": false, + "allowToChangeInstallationDirectory": false, + "runAfterFinish": false, + "createDesktopShortcut": false, + "createStartMenuShortcut": true, + "deleteAppDataOnUninstall": false }, "mac": { "target": [ diff --git a/apps/desktop/scripts/ade-cli-install-path.cmd b/apps/desktop/scripts/ade-cli-install-path.cmd index fe3949843..8f16ab9a9 100644 --- a/apps/desktop/scripts/ade-cli-install-path.cmd +++ b/apps/desktop/scripts/ade-cli-install-path.cmd @@ -56,8 +56,13 @@ if "%ADE_SKIP_USER_PATH_UPDATE%"=="1" ( exit /b 0 :ensure_user_path +setlocal DisableDelayedExpansion set "PATH_DIR=%~1" -powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$target=[System.IO.Path]::GetFullPath($args[0]).TrimEnd('\'); $current=[Environment]::GetEnvironmentVariable('Path','User'); $entries=if ([string]::IsNullOrWhiteSpace($current)) { @() } else { $current -split ';' | Where-Object { $_.Trim().Length -gt 0 } }; foreach ($entry in $entries) { try { if ([System.IO.Path]::GetFullPath($entry).TrimEnd('\').ToLowerInvariant() -eq $target.ToLowerInvariant()) { exit 0 } } catch {} }; $next=if ([string]::IsNullOrWhiteSpace($current)) { $target } else { $target + ';' + $current }; [Environment]::SetEnvironmentVariable('Path',$next,'User')" "%PATH_DIR%" >nul 2>nul +rem powershell.exe appends tokens after -Command to the command text instead of +rem exposing them through $args. Carry the path in the child environment so +rem spaces and PowerShell metacharacters remain data. +set "ADE_CLI_PATH_TARGET=%PATH_DIR%" +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$target=[System.IO.Path]::GetFullPath($env:ADE_CLI_PATH_TARGET).TrimEnd('\'); $current=[Environment]::GetEnvironmentVariable('Path','User'); $entries=if ([string]::IsNullOrWhiteSpace($current)) { @() } else { $current -split ';' | Where-Object { $_.Trim().Length -gt 0 } }; foreach ($entry in $entries) { try { if ([System.IO.Path]::GetFullPath($entry).TrimEnd('\').ToLowerInvariant() -eq $target.ToLowerInvariant()) { exit 0 } } catch {} }; $next=if ([string]::IsNullOrWhiteSpace($current)) { $target } else { $target + ';' + $current }; [Environment]::SetEnvironmentVariable('Path',$next,'User')" >nul 2>nul if errorlevel 1 ( echo ade install: failed to update the user PATH. Add %PATH_DIR% manually. 1>&2 exit /b 1 diff --git a/apps/desktop/scripts/ade-cli-windows-wrapper.cmd b/apps/desktop/scripts/ade-cli-windows-wrapper.cmd index e58ffd3c0..d6f00af0c 100644 --- a/apps/desktop/scripts/ade-cli-windows-wrapper.cmd +++ b/apps/desktop/scripts/ade-cli-windows-wrapper.cmd @@ -6,7 +6,22 @@ set "CLI_JS=%ADE_CLI_JS%" if "%CLI_JS%"=="" set "CLI_JS=%SCRIPT_DIR%..\cli.cjs" set "RESOURCES_DIR=%SCRIPT_DIR%..\.." -set "APP_EXE=%RESOURCES_DIR%\..\ADE.exe" +set "APP_EXE_NAME=ADE.exe" +set "CHANNEL_FILE=%SCRIPT_DIR%..\channel" +if exist "%CHANNEL_FILE%" ( + set /p ADE_BUNDLED_CHANNEL=<"%CHANNEL_FILE%" +) +if /I "%ADE_BUNDLED_CHANNEL%"=="beta" ( + set "APP_EXE_NAME=ADE Beta.exe" + if not defined ADE_PACKAGE_CHANNEL set "ADE_PACKAGE_CHANNEL=beta" + if not defined ADE_DESKTOP_APP_NAME set "ADE_DESKTOP_APP_NAME=ADE Beta" +) +if /I "%ADE_BUNDLED_CHANNEL%"=="alpha" ( + set "APP_EXE_NAME=ADE Alpha.exe" + if not defined ADE_PACKAGE_CHANNEL set "ADE_PACKAGE_CHANNEL=alpha" + if not defined ADE_DESKTOP_APP_NAME set "ADE_DESKTOP_APP_NAME=ADE Alpha" +) +set "APP_EXE=%RESOURCES_DIR%\..\%APP_EXE_NAME%" if not defined ADE_AGENT_SKILLS_DIRS if exist "%RESOURCES_DIR%\agent-skills" set "ADE_AGENT_SKILLS_DIRS=%RESOURCES_DIR%\agent-skills" set "NODE_PATH_VALUE=%RESOURCES_DIR%\app.asar.unpacked\node_modules;%RESOURCES_DIR%\app.asar\node_modules" if defined NODE_PATH ( diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs index 4214abed4..e788f3d5e 100644 --- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -66,7 +66,14 @@ function copyDirectoryIfPresent(sourcePath, targetPath) { function openCodeNativePackagesForPlatform(platform) { if (platform === "darwin") return ["opencode-darwin-arm64", "opencode-darwin-x64"]; - if (platform === "win32") return ["opencode-windows-x64", "opencode-windows-arm64"]; + // Windows x64 materializes the `-baseline` build, never `opencode-windows-x64`. + // The default x64 build requires AVX2 and aborts with an illegal instruction on + // x64 CPUs without it; `-baseline` targets the lower instruction set, is the same + // size, and runs everywhere. This list must stay in sync with + // OPENCODE_PLATFORM_PACKAGES in src/main/services/opencode/openCodeBinaryManager.ts, + // because the resolver only ever looks for the package named there - anything else + // materialized here is dead weight the installer still pays for. + if (platform === "win32") return ["opencode-windows-x64-baseline", "opencode-windows-arm64"]; if (platform === "linux") { return [ "opencode-linux-arm64", @@ -128,10 +135,10 @@ function ensureOpenCodeRuntimePackages(runtimeRoot, platform) { } function pruneOpenCodeInstallShim(runtimeRoot, platform) { - if (platform === "win32") return; const shimPath = path.join("node_modules", "opencode-ai", "bin", "opencode.exe"); if (removeIfPresent(runtimeRoot, shimPath)) { - console.log(`[afterPack] Pruned non-target OpenCode install shim: ${shimPath}`); + const reason = platform === "win32" ? "duplicate" : "non-target"; + console.log(`[afterPack] Pruned ${reason} OpenCode install shim: ${shimPath}`); } } @@ -197,12 +204,13 @@ function channelCliName(channel) { return "ade"; } -function materializeChannelCliWrapper(resourcesRoot, channel) { +function materializeChannelCliWrapper(resourcesRoot, channel, platform = "darwin") { if (!channel) return null; const cliRoot = path.join(resourcesRoot, "ade-cli"); const binRoot = path.join(cliRoot, "bin"); - const sourcePath = path.join(binRoot, "ade"); - const targetPath = path.join(binRoot, channelCliName(channel)); + const extension = platform === "win32" ? ".cmd" : ""; + const sourcePath = path.join(binRoot, `ade${extension}`); + const targetPath = path.join(binRoot, `${channelCliName(channel)}${extension}`); requireFile(sourcePath, "bundled ADE CLI wrapper"); fs.copyFileSync(sourcePath, targetPath); fs.chmodSync(targetPath, 0o755); @@ -415,13 +423,20 @@ module.exports = async function afterPack(context) { requireFile(bundledCliInstallerPath, "bundled ADE CLI PATH installer"); fs.chmodSync(bundledCliBinPath, 0o755); fs.chmodSync(bundledCliInstallerPath, 0o755); - const channelWrapperPath = materializeChannelCliWrapper(resourcesRoot, packageChannel); + const channelWrapperPath = materializeChannelCliWrapper(resourcesRoot, packageChannel, platform); if (channelWrapperPath) { console.log(`[afterPack] Added channel CLI wrapper: ${path.basename(channelWrapperPath)}`); } } else if (platform === "win32") { requireFile(path.join(resourcesRoot, "ade-cli", "bin", "ade.cmd"), "bundled ADE CLI Windows wrapper"); requireFile(path.join(resourcesRoot, "ade-cli", "install-path.cmd"), "bundled ADE CLI Windows PATH installer"); + requireFile(path.join(resourcesRoot, "ade-cli", "windows-uninstall-cleanup.ps1"), "bundled Windows uninstall cleanup script"); + requireFile(path.join(resourcesRoot, "ade-cli", "windows-install-setup.ps1"), "bundled Windows install setup script"); + requireFile(path.join(resourcesRoot, "ade-cli", "windows-firewall-rules.ps1"), "bundled Windows firewall rule script"); + const channelWrapperPath = materializeChannelCliWrapper(resourcesRoot, packageChannel, platform); + if (channelWrapperPath) { + console.log(`[afterPack] Added channel CLI wrapper: ${path.basename(channelWrapperPath)}`); + } } else { requireFile(path.join(resourcesRoot, "ade-cli", "bin", "ade"), "bundled ADE CLI wrapper"); requireFile(path.join(resourcesRoot, "ade-cli", "install-path.sh"), "bundled ADE CLI PATH installer"); diff --git a/apps/desktop/scripts/materialize-runtime-resources.mjs b/apps/desktop/scripts/materialize-runtime-resources.mjs index 0d5064232..80fc15018 100644 --- a/apps/desktop/scripts/materialize-runtime-resources.mjs +++ b/apps/desktop/scripts/materialize-runtime-resources.mjs @@ -15,7 +15,7 @@ const repoRoot = path.resolve(desktopRoot, "..", ".."); const cliRoot = path.join(repoRoot, "apps", "ade-cli"); const runtimeRoot = path.join(desktopRoot, "resources", "runtime"); const cliDistStaticRoot = path.join(cliRoot, "dist-static"); -const targets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; +const targets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-x64"]; const seaFuse = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2"; const allowHostOnlyRuntimeResources = process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1"; const maxDownloadRedirects = 10; @@ -31,11 +31,11 @@ function npmCommand() { } function artifactNamesForTarget(target) { - return [`ade-${target}`, `ade-${target}.native.tar.gz`]; + return [target === "win32-x64" ? `ade-${target}.exe` : `ade-${target}`, `ade-${target}.native.tar.gz`]; } function isRuntimeBinaryName(name) { - return targets.some((target) => name === `ade-${target}`); + return targets.some((target) => name === artifactNamesForTarget(target)[0]); } function isRuntimeArtifactName(name) { diff --git a/apps/desktop/scripts/run-electron-builder.mjs b/apps/desktop/scripts/run-electron-builder.mjs new file mode 100644 index 000000000..2e52de0d9 --- /dev/null +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -0,0 +1,181 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + resolveWindowsPackageIdentity, + windowsInstallerArtifactName, +} from "./windows-package-identity.mjs"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const desktopRoot = path.resolve(scriptDir, ".."); +const pkg = JSON.parse(fs.readFileSync(path.join(desktopRoot, "package.json"), "utf8")); +const requireSigningIndex = process.argv.indexOf("--require-signing"); +const requireSigning = requireSigningIndex >= 0; +const builderArgs = process.argv.slice(2).filter((arg) => arg !== "--require-signing"); +const configuredRepository = ( + process.env.ADE_RELEASE_REPOSITORY?.trim() + || `${pkg.build?.publish?.owner ?? ""}/${pkg.build?.publish?.repo ?? ""}` +).replace(/^\/+|\/+$/g, ""); +const repositoryMatch = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(configuredRepository); +const channelIdentity = resolveWindowsPackageIdentity(process.env.ADE_PACKAGE_CHANNEL); +const { packageChannel } = channelIdentity; +// Windows code signing runs against Azure Artifact Signing (the service +// formerly called Trusted Signing). There is no PFX to carry: CA/Browser Forum +// rules have required code-signing private keys to live on FIPS-validated +// hardware since June 2023, and this service never releases the certificate - +// it is held in the service and reachable only at the moment of signing. The +// only signing material the build sees is a Microsoft Entra service principal. +const AZURE_SIGNING_CREDENTIAL_ENV = ["AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET"]; +// Non-secret account coordinates. They are pinned here, not in CI, so the same +// values are used by a maintainer running dist:win:signed locally; each one is +// still overridable for a fork or a migrated account. +const azureSigningEndpoint = + process.env.WINDOWS_SIGNING_ENDPOINT?.trim() || "https://eus.codesigning.azure.net"; +const azureSigningAccountName = + process.env.WINDOWS_SIGNING_ACCOUNT_NAME?.trim() || "arulsigning"; +const azureCertificateProfileName = + process.env.WINDOWS_SIGNING_CERTIFICATE_PROFILE?.trim() || "adePublicTrust"; +// One pinned publisher value serves two jobs: electron-builder writes it into +// app-update.yml as the publisherName electron-updater checks before it runs a +// downloaded installer, and validate-win-artifacts.mjs asserts the same string +// against the Authenticode signer of the built artifacts. electron-updater +// parses it as a Distinguished Name and warns when it is given only a CN, so +// this must be the complete Subject. +const expectedSigningSubject = process.env.WINDOWS_SIGNING_EXPECTED_SUBJECT?.trim() ?? ""; +const configuredFileAssociation = Array.isArray(pkg.build?.fileAssociations) + ? pkg.build.fileAssociations[0] + : pkg.build?.fileAssociations; +if (!configuredFileAssociation || !Array.isArray(configuredFileAssociation.ext)) { + throw new Error("Windows packaging requires the configured ADE file association extension list."); +} +// CSC_LINK/CSC_KEY_PASSWORD are the macOS Developer ID secrets. electron-builder +// reads them on Windows too, so they are stripped unconditionally rather than +// left to be picked up as an unexpected Windows signing identity. The Azure +// credentials are stripped from the base environment for the same reason and +// handed back only on the signed path, so an unsigned dist:win can never reach +// the signing service. +const baseChildEnv = { ...process.env }; +delete baseChildEnv.CSC_LINK; +delete baseChildEnv.CSC_KEY_PASSWORD; +for (const name of AZURE_SIGNING_CREDENTIAL_ENV) { + delete baseChildEnv[name]; +} + +if (!repositoryMatch) { + throw new Error( + `ADE_RELEASE_REPOSITORY must be a GitHub owner/repo pair, received: ${configuredRepository || "empty"}`, + ); +} + +if (requireSigning) { + const missingSecrets = AZURE_SIGNING_CREDENTIAL_ENV.filter((name) => !process.env[name]?.trim()); + if (missingSecrets.length > 0) { + throw new Error( + `Signed Windows packaging requires ${missingSecrets.join(", ")}. ` + + "Unsigned artifacts are allowed only through npm run dist:win.", + ); + } + if (!expectedSigningSubject) { + throw new Error( + "Signed Windows packaging requires WINDOWS_SIGNING_EXPECTED_SUBJECT, the complete certificate " + + "Subject of the Azure Artifact Signing certificate profile, so the packaged updater and the " + + "release validator pin the same publisher.", + ); + } + // Azure Artifact Signing renews its certificate daily and expires it after 72 + // hours, so a pinned thumbprint stops matching within days. Reject the name + // instead of ignoring it, so nobody sets it and believes it pinned something. + if (process.env.WINDOWS_SIGNING_EXPECTED_THUMBPRINT?.trim()) { + throw new Error( + "WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline. " + + "The service renews its certificate daily and expires it after 72 hours, so a pinned thumbprint " + + "would fail every release within days. Unset it and pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead.", + ); + } +} + +const [, owner, repo] = repositoryMatch; +const electronBuilderBin = path.join( + desktopRoot, + "node_modules", + ".bin", + process.platform === "win32" ? "electron-builder.cmd" : "electron-builder", +); +const args = [ + ...builderArgs, + `--config.publish.owner=${owner}`, + `--config.publish.repo=${repo}`, + `--config.extraMetadata.adeReleaseRepository=${configuredRepository}`, + `--config.appId=${channelIdentity.appId}`, + `--config.productName=${channelIdentity.productName}`, + `--config.win.executableName=${channelIdentity.productName}`, + // The product name carries a space on channel builds ("ADE Beta"), which + // would leave the installer, latest.yml, and the published GitHub asset with + // three different names. Pin the artifact name to the space-free channel base + // so the updater feed can only ever reference the file published beside it. + `--config.win.artifactName=${windowsInstallerArtifactName(channelIdentity)}`, + `--config.fileAssociations.name=${channelIdentity.fileClass}`, + `--config.fileAssociations.description=${configuredFileAssociation.description ?? "ADE files"}`, + ...configuredFileAssociation.ext.map((extension) => `--config.fileAssociations.ext=${extension}`), + // electron-builder 26 selects the Azure signing manager purely on the + // presence of win.azureSignOptions, and that selection sits above the single + // signIf() chokepoint every Windows artifact passes through - the packaged + // channel executable and its bundled DLLs, the NSIS installer, and the + // uninstaller. That is why the service is wired in here rather than as a + // separate post-build workflow step: a step that ran after packaging could + // only sign the installer, leaving the ADE.exe already embedded inside it + // unsigned unless the installer were unpacked and rebuilt. + ...(requireSigning + ? [ + "--config.forceCodeSigning=true", + `--config.win.azureSignOptions.publisherName=${expectedSigningSubject}`, + `--config.win.azureSignOptions.endpoint=${azureSigningEndpoint}`, + `--config.win.azureSignOptions.codeSigningAccountName=${azureSigningAccountName}`, + `--config.win.azureSignOptions.certificateProfileName=${azureCertificateProfileName}`, + "--config.win.azureSignOptions.fileDigest=SHA256", + // Timestamping is not optional here. The signing certificate is valid + // for 72 hours, so without an RFC3161 countersignature every shipped + // installer would stop verifying three days after it was built. + "--config.win.azureSignOptions.timestampRfc3161=http://timestamp.acs.microsoft.com", + "--config.win.azureSignOptions.timestampDigest=SHA256", + ] + : []), +]; + +console.log( + `[windows-package] Building ${channelIdentity.productName} for ${owner}/${repo}${requireSigning ? " with required Azure Artifact Signing" : " (unsigned allowed)"}.`, +); +const childEnv = { + ...baseChildEnv, + ADE_PACKAGE_CHANNEL: packageChannel === "stable" ? "" : packageChannel, + ADE_DESKTOP_APP_NAME: channelIdentity.productName, + // electron-builder authenticates to Microsoft Entra ID with Azure.Identity's + // EnvironmentCredential, which reads exactly these names. It is first in the + // credential chain, so a complete service-principal triple is resolved before + // any managed-identity probe against the Azure IMDS endpoint - which a + // GitHub-hosted runner does not have. + ...(requireSigning + ? Object.fromEntries( + AZURE_SIGNING_CREDENTIAL_ENV.map((name) => [name, process.env[name]]), + ) + : {}), +}; +const electronBuilderCommand = process.platform === "win32" ? process.execPath : electronBuilderBin; +const electronBuilderArgs = process.platform === "win32" + ? [path.join(desktopRoot, "node_modules", "electron-builder", "out", "cli", "cli.js"), ...args] + : args; +const child = spawn(electronBuilderCommand, electronBuilderArgs, { + cwd: desktopRoot, + env: childEnv, + stdio: "inherit", + shell: false, + windowsHide: process.platform === "win32", +}); +child.once("error", (error) => { + console.error(`[windows-package] Unable to start electron-builder: ${error.message}`); + process.exitCode = 1; +}); +child.once("close", (code) => { + process.exitCode = code ?? 1; +}); diff --git a/apps/desktop/scripts/run-windows-test-build.mjs b/apps/desktop/scripts/run-windows-test-build.mjs new file mode 100644 index 000000000..fbc01841a --- /dev/null +++ b/apps/desktop/scripts/run-windows-test-build.mjs @@ -0,0 +1,33 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +if (process.platform !== "win32") { + throw new Error("dist:win:test must run on Windows."); +} + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const desktopRoot = path.resolve(scriptDir, ".."); +console.log( + "[windows-test-build] Building an unsigned local installer without macOS/Linux remote runtime sidecars.", +); + +const child = spawn("npm.cmd", ["run", "dist:win"], { + cwd: desktopRoot, + env: { + ...process.env, + ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY: "1", + ADE_WINDOWS_TEST_BUILD: "1", + }, + stdio: "inherit", + windowsHide: true, + shell: true, +}); + +child.once("error", (error) => { + console.error(`[windows-test-build] Unable to start the build: ${error.message}`); + process.exitCode = 1; +}); +child.once("close", (code) => { + process.exitCode = code ?? 1; +}); diff --git a/apps/desktop/scripts/validate-runtime-resources.mjs b/apps/desktop/scripts/validate-runtime-resources.mjs index 2d92e4ed0..93ad940dd 100644 --- a/apps/desktop/scripts/validate-runtime-resources.mjs +++ b/apps/desktop/scripts/validate-runtime-resources.mjs @@ -16,8 +16,10 @@ function currentTarget() { return `${platform}-${arch}`; } -const targets = process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1" - ? [currentTarget()] +const allowHostOnlyRuntimeResources = process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1"; +const hostTarget = currentTarget(); +const targets = allowHostOnlyRuntimeResources + ? allTargets.includes(hostTarget) ? [hostTarget] : [] : allTargets; function fail(message) { @@ -65,7 +67,7 @@ async function main() { await validateNativeArchive(path.join(runtimeRoot, `ade-${target}.native.tar.gz`), target); } - const mode = process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1" ? "host-only local" : "full"; + const mode = allowHostOnlyRuntimeResources ? "host-only local" : "full"; console.log(`[runtime-resources] Found ${targets.length} ${mode} ADE service binaries and native archives.`); } diff --git a/apps/desktop/scripts/validate-whisper-resources.mjs b/apps/desktop/scripts/validate-whisper-resources.mjs index fa57a77c8..59c87c2f1 100644 --- a/apps/desktop/scripts/validate-whisper-resources.mjs +++ b/apps/desktop/scripts/validate-whisper-resources.mjs @@ -116,6 +116,12 @@ async function main() { // A whisper.cpp CLI binary for the host platform must be present + executable. const binary = await firstExistingBinary(); if (!binary) { + if (process.platform === "win32" && process.env.ADE_WINDOWS_TEST_BUILD === "1") { + console.warn( + "[whisper-resources] Local Windows test build: Whisper CLI is not bundled; voice transcription will be unavailable.", + ); + return; + } fail( `No whisper.cpp CLI binary found in ${whisperRoot} (looked for ${whisperBinaryNamesForHost().join(", ")}).`, ); diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index 04e44fcea..9b9c723ad 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -7,6 +7,13 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import asar from "@electron/asar"; import { parse as parseYaml } from "yaml"; import packagedAdeCliResourcesModule from "./packaged-ade-cli-resources.cjs"; +import { createAuthenticodeProbe } from "./windows-authenticode.mjs"; +import { + isGithubSafeAssetName, + resolveWindowsPackageIdentity, + windowsInstallerArtifactName, + windowsInstallerPattern, +} from "./windows-package-identity.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const desktopRoot = path.resolve(__dirname, ".."); @@ -16,16 +23,24 @@ const { missingRequiredPackagedAdeCliPayloadPaths, packagedAdeCliPayloadFiles, } = packagedAdeCliResourcesModule; -const productName = pkg.build?.productName ?? pkg.productName ?? "ADE"; +const packageIdentity = resolveWindowsPackageIdentity(process.env.ADE_PACKAGE_CHANNEL); +const productName = packageIdentity.productName; const DEFAULT_MAX_APP_ASAR_BYTES = 900 * 1024 * 1024; // The unpacked runtime includes x64 Codex, Claude, OpenCode, node-pty, and // ONNX payloads. The afterPack step now also materializes the bundled ADE -// runtime's own OpenCode packages (opencode-ai + the platform native package, -// ~150MB) into app.asar.unpacked so the packaged runtime can launch OpenCode, -// which raises the legitimate unpacked size. Keep a ceiling to catch runaway -// bloat, but size it to the current required toolset. +// runtime's platform-native OpenCode package into app.asar.unpacked so the +// packaged runtime can launch OpenCode. Keep a ceiling to catch runaway bloat, +// but size it to the current required toolset. const DEFAULT_MAX_UNPACKED_BYTES = 1000 * 1024 * 1024; -const REMOTE_RUNTIME_TARGETS = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; +const REMOTE_RUNTIME_TARGETS = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-x64"]; + +function remoteRuntimeBinaryName(target) { + return target === "win32-x64" ? `ade-${target}.exe` : `ade-${target}`; +} +const isLocalWindowsTestBuild = + process.platform === "win32" && + process.env.ADE_WINDOWS_TEST_BUILD === "1" && + process.env.ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY === "1"; const bundledAgentSkills = [ "ade-cli-control-plane", "ade-ios-simulator", @@ -73,6 +88,35 @@ function shouldRequireSignedArtifacts() { return hasFlag("--require-signed") || process.env.ADE_REQUIRE_WIN_SIGNING === "1"; } +function normalizeCertificateThumbprint(value) { + return value?.replace(/\s+/g, "").toUpperCase() ?? ""; +} + +// Azure Artifact Signing never releases the signing certificate: it mints a +// short-lived leaf per profile, renews it daily, and expires it after 72 hours. +// A pinned thumbprint would therefore reject every release within days, so the +// publisher pin is the certificate Subject and only the Subject. The thumbprint +// name is rejected outright rather than quietly ignored, so nobody sets it and +// then discovers days later that it pinned nothing. +function expectedWindowsSigningIdentity() { + if (!shouldRequireSignedArtifacts()) return null; + const subject = process.env.WINDOWS_SIGNING_EXPECTED_SUBJECT?.trim() ?? ""; + if (process.env.WINDOWS_SIGNING_EXPECTED_THUMBPRINT?.trim()) { + fail( + "WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported by the Azure Artifact Signing pipeline. " + + "The service renews its certificate daily and expires it after 72 hours, so a pinned thumbprint " + + "would fail every release within days. Unset it and pin WINDOWS_SIGNING_EXPECTED_SUBJECT instead.", + ); + } + if (!subject) { + fail( + "Signed Windows validation requires WINDOWS_SIGNING_EXPECTED_SUBJECT " + + "so the release cannot be signed by an unexpected publisher.", + ); + } + return { subject }; +} + function resolveAbsolute(input) { if (!input) return null; return path.isAbsolute(input) ? input : path.resolve(desktopRoot, input); @@ -170,6 +214,49 @@ async function assertPathMissing(targetPath, description) { fail(`Unexpected ${description}: ${targetPath}`); } +function collectAsarEmbeddedFiles(node, prefix, out) { + for (const [name, entry] of Object.entries(node?.files ?? {})) { + const entryPath = `${prefix}/${name}`; + if (entry?.files) { + collectAsarEmbeddedFiles(entry, entryPath, out); + } else if (!entry?.unpacked) { + // `unpacked: true` entries are index stubs; their bytes live in + // app.asar.unpacked, not inside the archive. + out.push({ path: entryPath, size: Number(entry?.size) || 0 }); + } + } +} + +/** + * OpenCode's platform packages are single ~141.5 MB native executables. They are + * only usable from app.asar.unpacked - an exe embedded in app.asar cannot be + * spawned - so any embedded copy is dead weight that still ships in the + * installer. Dropping a package from `asarUnpack` without also excluding it in + * `build.files` moves it *into* app.asar rather than removing it, which is + * invisible in the unpacked-tree checks above; this catches that regression. + */ +async function assertAsarEmbedsNoOpenCodeNativePayload(appAsarPath) { + let header; + try { + header = asar.getRawHeader(appAsarPath).header; + } catch (error) { + fail(`Unable to read app.asar header for OpenCode payload hygiene: ${error?.message ?? error}`); + return; + } + const embedded = []; + collectAsarEmbeddedFiles(header, "", embedded); + // `node_modules/opencode-*` covers the native packages only; the JS SDK is the + // scoped `node_modules/@opencode-ai/sdk` and legitimately lives in the archive. + const offenders = embedded.filter((entry) => entry.path.startsWith("/node_modules/opencode-")); + if (offenders.length === 0) return; + const bytes = offenders.reduce((total, entry) => total + entry.size, 0); + fail( + `app.asar embeds ${offenders.length} OpenCode native payload file(s) totalling ${bytes} bytes; ` + + "they must be excluded via build.files or unpacked via asarUnpack, never packed into the archive: " + + `${offenders.slice(0, 5).map((entry) => entry.path).join(", ")}`, + ); +} + async function assertExecutable(targetPath, description) { if (process.platform === "win32") { return; @@ -187,10 +274,6 @@ function requireFile(relativePath, label) { } } -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - function parseWinTargets() { const targets = pkg.build?.win?.target; if (!Array.isArray(targets)) return []; @@ -210,10 +293,33 @@ function parseWinTargets() { }); } +function resolveExpectedReleaseRepository() { + const configured = ( + process.env.ADE_RELEASE_REPOSITORY?.trim() + || `${pkg.build?.publish?.owner ?? ""}/${pkg.build?.publish?.repo ?? ""}` + ).replace(/^\/+|\/+$/g, ""); + const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(configured); + if (!match) { + fail(`Expected ADE_RELEASE_REPOSITORY to be owner/repo, received: ${configured || "empty"}`); + } + return { owner: match[1], repo: match[2] }; +} + +function runtimeResourceFilter() { + return [ + ...(Array.isArray(pkg.build?.extraResources) ? pkg.build.extraResources : []), + ...(Array.isArray(pkg.build?.win?.extraResources) ? pkg.build.win.extraResources : []), + ].filter((entry) => entry?.to === "runtime") + .flatMap((entry) => Array.isArray(entry?.filter) ? entry.filter : []); +} function validatePreflight() { requireFile("build/icon.ico", "Windows app icon"); requireFile("scripts/ade-cli-windows-wrapper.cmd", "Windows ADE CLI wrapper"); requireFile("scripts/ade-cli-install-path.cmd", "Windows ADE CLI PATH installer"); + requireFile("scripts/windows-install-setup.ps1", "Windows install setup script"); + requireFile("scripts/windows-uninstall-cleanup.ps1", "Windows uninstall cleanup script"); + requireFile("scripts/windows-firewall-rules.ps1", "Windows firewall rule script"); + requireFile("build/installer.nsh", "Windows NSIS customization"); requireFile("vendor/crsqlite/win32-x64/crsqlite.dll", "Windows cr-sqlite extension"); assertRequiredBundledAdeCliFiles(resolveBundledAdeCliFiles({ allowMissingSources: true })); @@ -226,9 +332,60 @@ function validatePreflight() { if (!Array.isArray(pkg.build?.asarUnpack) || !pkg.build.asarUnpack.includes("node_modules/opencode-ai/**")) { fail("package.json build.asarUnpack must unpack node_modules/opencode-ai/** for the bundled OpenCode CLI"); } + // The default `opencode-windows-x64` build requires AVX2 and aborts with an + // illegal instruction on x64 CPUs without it. The `-baseline` build targets + // the lower instruction set, runs on every x64 CPU, and is the same size, so + // Windows x64 ships baseline only. Keep this in sync with + // OPENCODE_PLATFORM_PACKAGES in src/main/services/opencode/openCodeBinaryManager.ts. + if (!Array.isArray(pkg.build?.asarUnpack) || !pkg.build.asarUnpack.includes("node_modules/opencode-windows-x64-baseline/**")) { + fail("package.json build.asarUnpack must unpack node_modules/opencode-windows-x64-baseline/** so non-AVX2 x64 machines get a runnable OpenCode binary"); + } + if (Array.isArray(pkg.build?.asarUnpack) && pkg.build.asarUnpack.includes("node_modules/opencode-windows-x64/**")) { + fail("package.json build.asarUnpack must not unpack node_modules/opencode-windows-x64/**; it is the AVX2-only build that crashes on older x64 CPUs"); + } + // Dropping the AVX2 package from asarUnpack is not enough to stop shipping it: + // electron-builder always copies production dependencies, so a package that is + // not unpacked is embedded *inside* app.asar instead - 141.5 MB of binary that + // can never be executed from there and that nothing resolves. Only a `!` pattern + // in build.files keeps it out of the package entirely (getNodeModuleFileMatcher + // collects exactly the negated patterns and applies them to the node_modules walk). + if (!Array.isArray(pkg.build?.files) || !pkg.build.files.includes("!node_modules/opencode-windows-x64/**")) { + fail("package.json build.files must exclude !node_modules/opencode-windows-x64/**; otherwise the unused AVX2 OpenCode build is embedded in app.asar"); + } if (pkg.build?.win?.icon !== "build/icon.ico") { fail("package.json build.win.icon must point to build/icon.ico"); } + // ${productName} expands to "ADE Beta" on channel builds. electron-builder + // would then write the installer with a space while rewriting latest.yml's + // url/path to the space-free name it would have used for its own GitHub + // upload, and `gh release upload` publishes the on-disk name instead - so the + // updater feed would point at a file that was never published. + const stableArtifactName = windowsInstallerArtifactName(resolveWindowsPackageIdentity("stable")); + if (pkg.build?.win?.artifactName !== stableArtifactName) { + fail( + `package.json build.win.artifactName must be ${stableArtifactName} so the installer name never inherits a space from productName`, + ); + } + if ( + pkg.build?.nsis?.oneClick !== false + || pkg.build?.nsis?.perMachine !== false + || pkg.build?.nsis?.allowElevation !== false + || pkg.build?.nsis?.runAfterFinish !== false + ) { + fail("package.json build.nsis must pin the Windows installer to a non-elevating per-user lifecycle"); + } + // electron-builder defaults uninstallDisplayName to "${productName} ${version}", + // which writes an Add/Remove Programs DisplayName that changes on every + // release and reads as a different product each time. Windows expects + // DisplayName to carry the product and DisplayVersion to carry the version, + // and Stable/Beta side-by-side installs are only distinguishable when each + // channel owns one stable DisplayName. + // eslint-disable-next-line no-template-curly-in-string + if (pkg.build?.nsis?.uninstallDisplayName !== "${productName}") { + fail( + "package.json build.nsis.uninstallDisplayName must be ${productName} so each channel keeps one version-independent Add/Remove Programs entry", + ); + } const winTargets = parseWinTargets(); if (winTargets.length === 0) { @@ -238,13 +395,27 @@ function validatePreflight() { fail("package.json build.win.target must pin NSIS to x64 until a Windows ARM64 cr-sqlite binary is bundled"); } - if (typeof pkg.scripts?.["dist:win"] !== "string" || !/\s--x64(?:\s|$)/.test(pkg.scripts["dist:win"])) { - fail("package.json scripts.dist:win must pass --x64 until a Windows ARM64 cr-sqlite binary is bundled"); + if (typeof pkg.scripts?.["package:win"] !== "string" || !/\s--x64(?:\s|$)/.test(pkg.scripts["package:win"])) { + fail("package.json scripts.package:win must pass --x64 until a Windows ARM64 cr-sqlite binary is bundled"); } if (typeof pkg.scripts?.["dist:win"] !== "string" || !pkg.scripts["dist:win"].includes("validate:win:release")) { fail("package.json scripts.dist:win must validate the packaged Windows release output"); } + const runtimeFilter = new Set(runtimeResourceFilter()); + for (const target of REMOTE_RUNTIME_TARGETS) { + for (const fileName of [remoteRuntimeBinaryName(target), `ade-${target}.native.tar.gz`]) { + if (!runtimeFilter.has(fileName)) { + fail(`package.json build.extraResources runtime filter must include ${fileName}`); + } + } + } + + const staticUpdateResource = pkg.build?.extraResources?.find((entry) => entry?.to === "app-update.yml"); + if (staticUpdateResource) { + fail("app-update.yml must be generated from electron-builder publish configuration, not copied as a static extraResource"); + } + console.log("[validate-win-artifacts] Windows package inputs are present."); } @@ -369,7 +540,7 @@ async function assertRemoteRuntimeBundle(resourcesPath) { const runtimeRoot = path.join(resourcesPath, "runtime"); await assertPathExists(runtimeRoot, "remote runtime bundle directory"); for (const target of REMOTE_RUNTIME_TARGETS) { - const binaryPath = path.join(runtimeRoot, `ade-${target}`); + const binaryPath = path.join(runtimeRoot, remoteRuntimeBinaryName(target)); const nativeArchivePath = path.join(runtimeRoot, `ade-${target}.native.tar.gz`); await assertPathExists(binaryPath, `remote runtime binary ${target}`); await assertExecutable(binaryPath, `remote runtime binary ${target}`); @@ -476,17 +647,27 @@ async function validatePackageHygiene(resourcesPath) { await assertPathMissing(path.join(unpackedPath, "node_modules", "@openai", "codex-linux-x64"), "Codex Linux x64 payload in Windows package"); await assertPathMissing(path.join(unpackedPath, "node_modules", "@cursor", "sdk-darwin-arm64"), "Cursor macOS arm64 payload in Windows package"); await assertPathMissing(path.join(unpackedPath, "node_modules", "@cursor", "sdk-darwin-x64"), "Cursor macOS x64 payload in Windows package"); + await assertPathExists(path.join(unpackedPath, "node_modules", "@cursor", "sdk-win32-x64", "bin", "rg.exe"), "Cursor Windows x64 ripgrep helper"); + await assertPathExists(path.join(unpackedPath, "node_modules", "@cursor", "sdk-win32-x64", "bin", "cursorsandbox.exe"), "Cursor Windows x64 sandbox helper"); await assertPathMissing(path.join(unpackedPath, "node_modules", "node-pty", "build", "Release", "conpty"), "duplicate node-pty build conpty payload in Windows package"); await assertPathMissing( path.join(unpackedPath, "node_modules", "node-pty", "third_party", "conpty", "1.23.251008001", "win10-arm64"), "node-pty Windows arm64 conpty payload in Windows x64 package", ); // The afterPack step (ensureOpenCodeRuntimePackages) now deliberately bundles - // the on-target OpenCode native package into app.asar.unpacked so opencode-ai - // can resolve its sibling `opencode.exe` at runtime. Require it present; the - // off-target / baseline / arm64 variants below must still be absent. - await assertPathExists(path.join(unpackedPath, "node_modules", "opencode-windows-x64"), "bundled OpenCode Windows x64 payload in Windows package"); - await assertPathMissing(path.join(unpackedPath, "node_modules", "opencode-windows-x64-baseline"), "baseline OpenCode Windows x64 payload in Windows package"); + // the on-target OpenCode native package into app.asar.unpacked. Require it + // present; the duplicate opencode-ai install shim and all off-target variants + // must be absent. Windows x64's on-target package is `-baseline`: it is what + // OPENCODE_PLATFORM_PACKAGES resolves, and the AVX2 `opencode-windows-x64` + // build must not ship at all - it crashes on non-AVX2 CPUs and, since nothing + // resolves it, any copy of it is 141.5 MB of pure installer weight. + await assertPathExists( + path.join(unpackedPath, "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe"), + "bundled baseline OpenCode Windows x64 executable in Windows package", + ); + await assertPathMissing(path.join(unpackedPath, "node_modules", "opencode-ai", "bin", "opencode.exe"), "duplicate OpenCode Windows executable"); + await assertPathMissing(path.join(unpackedPath, "node_modules", "opencode-windows-x64"), "AVX2-only OpenCode Windows x64 payload in Windows package"); + await assertAsarEmbedsNoOpenCodeNativePayload(appAsarPath); await assertPathMissing(path.join(unpackedPath, "node_modules", "opencode-windows-arm64"), "OpenCode Windows arm64 payload in Windows x64 package"); await assertPathMissing(path.join(unpackedPath, "node_modules", "opencode-darwin-arm64"), "OpenCode macOS arm64 payload in Windows package"); await assertPathMissing(path.join(unpackedPath, "node_modules", "opencode-darwin-x64"), "OpenCode macOS x64 payload in Windows package"); @@ -498,6 +679,22 @@ async function validatePackageHygiene(resourcesPath) { console.log("[validate-win-artifacts] Package hygiene passed."); } +async function validatePackagedUpdateAuthority(resourcesPath) { + const appUpdatePath = path.join(resourcesPath, "app-update.yml"); + await assertPathExists(appUpdatePath, "electron-builder app-update.yml"); + const updateConfig = parseYaml(await fsp.readFile(appUpdatePath, "utf8")); + const expected = resolveExpectedReleaseRepository(); + if ( + updateConfig?.provider !== "github" + || updateConfig?.owner !== expected.owner + || updateConfig?.repo !== expected.repo + ) { + fail( + `Packaged update authority must be github:${expected.owner}/${expected.repo}, got ` + + `${String(updateConfig?.provider)}:${String(updateConfig?.owner)}/${String(updateConfig?.repo)}`, + ); + } +} async function validatePackagedRuntime(appDir) { const appExe = path.join(appDir, `${productName}.exe`); const resourcesPath = path.join(appDir, "resources"); @@ -505,6 +702,7 @@ async function validatePackagedRuntime(appDir) { const unpackedPath = path.join(resourcesPath, "app.asar.unpacked"); const adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade.cmd"); const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.cmd"); + const uninstallCleanupPath = path.join(resourcesPath, "ade-cli", "windows-uninstall-cleanup.ps1"); const adeCliTuiPath = path.join(resourcesPath, "ade-cli", "tuiClient", "cli.mjs"); const bundledAgentSkillsRoot = path.join(resourcesPath, "agent-skills"); const nodeModulesPath = path.join(unpackedPath, "node_modules"); @@ -518,6 +716,7 @@ async function validatePackagedRuntime(appDir) { await assertPathExists(appExe, "packaged Windows app executable"); await assertPathExists(appAsarPath, "app.asar payload"); await assertPathExists(unpackedPath, "app.asar.unpacked runtime payload"); + await assertPathExists(uninstallCleanupPath, "packaged Windows uninstall cleanup script"); for (const resource of bundledAdeCliFiles) { await assertPathExists( path.join(resourcesPath, resource.to), @@ -535,7 +734,14 @@ async function validatePackagedRuntime(appDir) { fail(`Bundled ADE code TUI references ${token} without an ESM shim`); } } - await assertRemoteRuntimeBundle(resourcesPath); + if (isLocalWindowsTestBuild) { + console.warn( + "[validate-win-artifacts] Local test build: skipping macOS/Linux remote runtime sidecars.", + ); + } else { + await assertRemoteRuntimeBundle(resourcesPath); + } + await validatePackagedUpdateAuthority(resourcesPath); await validatePackageHygiene(resourcesPath); const nodePtyAddon = await findNodePtyAddon(nodePtyModulePath); @@ -569,29 +775,58 @@ async function validatePackagedRuntime(appDir) { if (!payload?.ptyProbe?.ok) { fail("Packaged smoke failed to execute a PTY probe"); } + if (!payload?.crsqliteProbe?.ok || Number(payload.crsqliteProbe.changeRows) < 1) { + fail("Packaged smoke failed to load crsqlite.dll and record a CRR change"); + } if (payload?.claudeQuery !== "function") { fail(`Packaged smoke expected Claude SDK query() to be available, got ${String(payload?.claudeQuery)}`); } if (typeof payload?.claudeExecutablePath !== "string" || payload.claudeExecutablePath.trim().length === 0) { fail("Packaged smoke did not report a Claude executable path"); } + if (payload?.claudeExecutableSource !== "bundled") { + fail(`Claude executable source must be bundled, got ${String(payload?.claudeExecutableSource)} at ${String(payload?.claudeExecutablePath)}`); + } + await assertPathExists(payload.claudeExecutablePath, "bundled Claude executable"); if (!payload?.claudeStartup || typeof payload.claudeStartup !== "object") { fail("Packaged smoke did not report a Claude startup result"); } if (payload.claudeStartup.state === "binary-missing") { - console.warn("[validate-win-artifacts] Claude CLI is not installed on this machine; skipping live Claude startup check."); + fail(`Packaged Claude executable could not start: ${String(payload.claudeStartup.message || "binary missing")}`); } else if (payload.claudeStartup.state === "runtime-failed") { fail(`Packaged smoke could not start Claude from the packaged app: ${String(payload.claudeStartup.message || "unknown error")}`); } if (payload?.codexExecutable !== "function") { fail(`Packaged smoke expected Codex executable resolver to be available, got ${String(payload?.codexExecutable)}`); } + if (payload?.codexExecutableSource !== "bundled") { + fail(`Codex executable source must be bundled, got ${String(payload?.codexExecutableSource)} at ${String(payload?.codexExecutablePath)}`); + } + await assertPathExists(payload.codexExecutablePath, "bundled Codex executable"); + await runCommand(payload.codexExecutablePath, ["--version"], { timeoutMs: 20_000 }); if (payload?.openCodeExecutable !== "function") { fail(`Packaged smoke expected OpenCode executable resolver to be available, got ${String(payload?.openCodeExecutable)}`); } if (payload?.openCodeExecutableSource !== "bundled") { fail(`Packaged smoke expected bundled OpenCode, got ${String(payload?.openCodeExecutableSource)} at ${String(payload?.openCodeExecutablePath)}`); } + await assertPathExists(payload.openCodeExecutablePath, "bundled OpenCode executable"); + await runCommand(payload.openCodeExecutablePath, ["--version"], { timeoutMs: 20_000 }); + if (payload?.cursorSdkCreateAgentPlatform !== "function") { + fail(`Packaged smoke expected Cursor SDK createAgentPlatform() to be available, got ${String(payload?.cursorSdkCreateAgentPlatform)}`); + } + await assertPathExists(payload.cursorNativeRgPath, "packaged Cursor ripgrep helper"); + await assertPathExists(payload.cursorNativeSandboxPath, "packaged Cursor sandbox helper"); + await runCommand(payload.cursorNativeRgPath, ["--version"], { timeoutMs: 20_000 }); + if (payload?.droidSdkCreateSession !== "function") { + fail(`Packaged smoke expected Droid SDK createSession() to be available, got ${String(payload?.droidSdkCreateSession)}`); + } + if (payload?.droidExecutableSource !== "fallback-command") { + await assertPathExists(payload.droidExecutablePath, "resolved user-installed Droid executable"); + await runCommand(payload.droidExecutablePath, ["--version"], { timeoutMs: 20_000 }); + } else { + console.log("[validate-win-artifacts] Droid SDK loaded; the optional user-managed Droid CLI is not installed on this package host."); + } const defaultHelp = await runCommand(adeCliBinPath, ["--help"], { cwd: resourcesPath, @@ -668,28 +903,18 @@ async function validatePackagedRuntime(appDir) { console.log(`[validate-win-artifacts] Windows packaged runtime smoke passed: ${path.relative(appDir, nodePtyAddon)}`); } -async function validateAuthenticodeSignature(filePath, description) { - if (!shouldRequireSignedArtifacts()) return; +async function validateAuthenticodeSignature(filePath, description, expectedIdentity) { + if (!shouldRequireSignedArtifacts()) return null; if (process.platform !== "win32") { fail(`Cannot verify Authenticode signature for ${description} on ${process.platform}; run signed Windows validation on Windows.`); } - const script = [ - "$sig = Get-AuthenticodeSignature -LiteralPath $args[0]", - "[pscustomobject]@{", - " Status = [string]$sig.Status;", - " StatusMessage = [string]$sig.StatusMessage;", - " Subject = if ($sig.SignerCertificate) { [string]$sig.SignerCertificate.Subject } else { $null }", - "} | ConvertTo-Json -Compress", - ].join("\n"); - const { stdout } = await runCommand("powershell.exe", [ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - script, - filePath, - ]); + // powershell.exe joins every token after -Command into the command text. + // Passing filePath as a trailing argv value therefore turns it into source + // code instead of $args[0]. Carry it in the child environment so paths with + // spaces and PowerShell metacharacters remain data. + const probe = createAuthenticodeProbe(filePath); + const { stdout } = await runCommand(probe.command, probe.args, { env: probe.env }); let payload; try { @@ -704,11 +929,28 @@ async function validateAuthenticodeSignature(filePath, description) { `${payload?.Status ?? "unknown"} ${payload?.StatusMessage ?? ""}`.trim(), ); } + if (!payload?.TimestampSubject) { + fail(`${description} has no trusted Authenticode timestamp`); + } + const identity = { + subject: String(payload?.Subject ?? "").trim(), + thumbprint: normalizeCertificateThumbprint(String(payload?.Thumbprint ?? "")), + }; + if (!identity.subject || !identity.thumbprint) { + fail(`${description} has no readable Authenticode signer identity`); + } + if (identity.subject.toLocaleLowerCase("en-US") !== expectedIdentity.subject.toLocaleLowerCase("en-US")) { + fail( + `${description} was signed by an unexpected publisher. ` + + `Expected "${expectedIdentity.subject}", received "${identity.subject}".`, + ); + } + return identity; } async function validateReleaseArtifacts() { const releaseDir = resolveAbsolute(readFlag("--release-dir")) ?? path.join(desktopRoot, "release"); - const installerRegex = new RegExp(`^${escapeRegExp(productName)}-.+-win-x64\\.exe$`); + const installerRegex = windowsInstallerPattern(packageIdentity); const installerPath = resolveAbsolute(readFlag("--installer")) ?? (await findArtifact(releaseDir, installerRegex, "Windows installer")); const installerBlockmapPath = @@ -718,12 +960,44 @@ async function validateReleaseArtifacts() { await assertPathExists(releaseDir, "release output directory"); await assertPathExists(installerPath, "Windows installer"); + const installerName = path.basename(installerPath); + if (!isGithubSafeAssetName(installerName)) { + fail( + `Windows installer name must contain only letters, digits, dots, dashes, and underscores so the built file, ` + + `latest.yml, and the published GitHub release asset stay byte-identical: ${installerName}`, + ); + } await assertPathExists(installerBlockmapPath, "Windows installer blockmap"); await assertPathExists(appDir, "win-unpacked app directory"); await validateLatestYaml(latestPath, installerPath); await validatePackagedRuntime(appDir); - await validateAuthenticodeSignature(installerPath, "Windows installer"); - await validateAuthenticodeSignature(path.join(appDir, `${productName}.exe`), "packaged Windows app executable"); + const expectedIdentity = expectedWindowsSigningIdentity(); + const installerIdentity = await validateAuthenticodeSignature( + installerPath, + "Windows installer", + expectedIdentity, + ); + const appIdentity = await validateAuthenticodeSignature( + path.join(appDir, `${productName}.exe`), + "packaged Windows app executable", + expectedIdentity, + ); + // Both artifacts are pinned to the same Subject above, but a Subject match + // alone would still accept two different certificates carrying that Subject. + // Requiring one thumbprint across the pair proves the installer and the + // executable it installs came from the same signing operation. Azure Artifact + // Signing rotates the leaf daily, so a build that straddles a rotation is the + // one legitimate way this can trip; rerun the build rather than relaxing it. + if ( + installerIdentity + && appIdentity + && installerIdentity.thumbprint !== appIdentity.thumbprint + ) { + fail( + "Windows installer and packaged executable were signed by different certificates: " + + `${installerIdentity.thumbprint} versus ${appIdentity.thumbprint}.`, + ); + } console.log("[validate-win-artifacts] Windows release artifacts passed updater and packaged-runtime checks."); } diff --git a/apps/desktop/scripts/windows-authenticode.mjs b/apps/desktop/scripts/windows-authenticode.mjs new file mode 100644 index 000000000..3da37358d --- /dev/null +++ b/apps/desktop/scripts/windows-authenticode.mjs @@ -0,0 +1,34 @@ +export const AUTHENTICODE_FILE_PATH_ENV = "ADE_WINDOWS_AUTHENTICODE_FILE_PATH"; + +export function createAuthenticodeProbe(filePath, baseEnv = process.env) { + const normalizedPath = String(filePath ?? "").trim(); + if (!normalizedPath) { + throw new Error("Authenticode validation requires a file path."); + } + + const script = [ + `$sig = Get-AuthenticodeSignature -LiteralPath $env:${AUTHENTICODE_FILE_PATH_ENV}`, + "[pscustomobject]@{", + " Status = [string]$sig.Status;", + " StatusMessage = [string]$sig.StatusMessage;", + " Subject = if ($sig.SignerCertificate) { [string]$sig.SignerCertificate.Subject } else { $null };", + " Thumbprint = if ($sig.SignerCertificate) { [string]$sig.SignerCertificate.Thumbprint } else { $null };", + " TimestampSubject = if ($sig.TimeStamperCertificate) { [string]$sig.TimeStamperCertificate.Subject } else { $null }", + "} | ConvertTo-Json -Compress", + ].join("\n"); + + return { + command: "powershell.exe", + args: [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ], + env: { + ...baseEnv, + [AUTHENTICODE_FILE_PATH_ENV]: normalizedPath, + }, + }; +} diff --git a/apps/desktop/scripts/windows-authenticode.test.mjs b/apps/desktop/scripts/windows-authenticode.test.mjs new file mode 100644 index 000000000..871cfa7e4 --- /dev/null +++ b/apps/desktop/scripts/windows-authenticode.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { spawnSync } from "node:child_process"; +import { createAuthenticodeProbe } from "./windows-authenticode.mjs"; + +test("Authenticode probe treats paths as data instead of PowerShell source", { + skip: process.platform !== "win32", +}, (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade signature probe ")); + t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); + const targetPath = path.join(tempRoot, "unsigned & untrusted.ps1"); + fs.writeFileSync(targetPath, "Write-Output 'unsigned'\n"); + + const probe = createAuthenticodeProbe(targetPath); + const result = spawnSync(probe.command, probe.args, { + env: probe.env, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(Object.hasOwn(payload, "Status")); + assert.doesNotMatch(result.stderr, /ParserError|positional parameter/i); + if (!/module could not be loaded/i.test(result.stderr)) { + assert.equal(payload.Status, "NotSigned"); + } +}); diff --git a/apps/desktop/scripts/windows-firewall-rules.ps1 b/apps/desktop/scripts/windows-firewall-rules.ps1 new file mode 100644 index 000000000..eff1317d8 --- /dev/null +++ b/apps/desktop/scripts/windows-firewall-rules.ps1 @@ -0,0 +1,192 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet("install", "uninstall")] + [string]$Action, + [Parameter(Mandatory = $true)] + [string]$InstallDir, + [Parameter(Mandatory = $true)] + [string]$AppExecutableName, + [ValidateSet("stable", "beta", "alpha")] + [string]$PackageChannel = "stable" +) + +# ADE's brain binds the sync host on all interfaces (0.0.0.0) so phones on the +# same wifi can reach it, and advertises itself over mDNS. Both listeners live +# in the packaged Electron executable, so without a pre-authorized rule Windows +# raises its "allow this app through the firewall" prompt on first run. +# +# Windows has no per-user firewall rule store: every write to the rule set +# requires Administrator. The ADE installer is deliberately per-user and +# non-elevating (build.nsis perMachine=false / allowElevation=false in +# apps/desktop/package.json, enforced by scripts/validate-win-artifacts.mjs), so +# this script usually runs at medium integrity and CANNOT create the rule. It +# does not pretend otherwise: when elevation is missing it makes no change and +# reports why, so the installer log states plainly that Windows will prompt once. +# It applies the rules when the installer does happen to run elevated (UAC off, +# built-in Administrator, or an admin-launched repair), and always tries to take +# them back out on uninstall. + +$ErrorActionPreference = "Stop" + +$SYNC_HOST_MIN_PORT = 8787 # DEFAULT_SYNC_HOST_PORT in apps/ade-cli/src/services/sync/syncProtocol.ts +$SYNC_HOST_MAX_PORT = 8999 # SYNC_HOST_MAX_PORT in the same module +$MDNS_PORT = 5353 # bonjour-service discovery used by the sync host + +function Get-ShortSha256([string]$Value) { + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Value)))).Replace("-", "").Substring(0, 12).ToLowerInvariant() + } finally { + $sha.Dispose() + } +} + +function Test-Elevated { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-NetshPath { + $systemRoot = $env:SystemRoot + if ([string]::IsNullOrWhiteSpace($systemRoot)) { + throw "SystemRoot is unavailable; ADE cannot locate netsh.exe." + } + $netsh = Join-Path $systemRoot "System32\netsh.exe" + if (-not (Test-Path -LiteralPath $netsh -PathType Leaf)) { + throw "netsh.exe is missing from $([IO.Path]::GetDirectoryName($netsh))." + } + return $netsh +} + +function Invoke-Netsh([string]$NetshPath, [string[]]$Arguments) { + $output = (& $NetshPath @Arguments 2>&1 | Out-String).Trim() + return @{ ExitCode = $LASTEXITCODE; Output = $output } +} + +function Remove-Rule([string]$NetshPath, [string]$RuleName) { + # `delete rule` removes every rule carrying the name, so running it before + # `add rule` makes a reinstall over an existing install idempotent instead of + # stacking duplicates. It exits 1 with "No rules match" when nothing is there, + # which is a success for our purposes. + $result = Invoke-Netsh $NetshPath @("advfirewall", "firewall", "delete", "rule", "name=$RuleName") + return $result +} + +function Test-RuleExists([string]$NetshPath, [string]$RuleName) { + $result = Invoke-Netsh $NetshPath @("advfirewall", "firewall", "show", "rule", "name=$RuleName") + return $result.ExitCode -eq 0 +} + +$resolvedInstallDir = [IO.Path]::GetFullPath($InstallDir).TrimEnd("\") +$normalizedExecutableName = [IO.Path]::GetFileName($AppExecutableName) +if (-not [string]::Equals($normalizedExecutableName, $AppExecutableName, [StringComparison]::Ordinal) -or + -not $normalizedExecutableName.EndsWith(".exe", [StringComparison]::OrdinalIgnoreCase)) { + throw "The installer did not provide a valid ADE executable name." +} +$appExe = Join-Path $resolvedInstallDir $normalizedExecutableName + +# Two installs of the same channel in different locations (or per-user installs +# owned by different Windows accounts) must not fight over one rule name, and +# the uninstaller has to be able to recompute the exact name it created. +$installIdentity = Get-ShortSha256 "$($PackageChannel.ToLowerInvariant())`0$($resolvedInstallDir.ToLowerInvariant())" +$syncRuleName = "ADE Sync Host ($PackageChannel-$installIdentity)" +$discoveryRuleName = "ADE LAN Discovery ($PackageChannel-$installIdentity)" + +$netsh = Get-NetshPath +$elevated = Test-Elevated + +if ($Action -eq "uninstall") { + if (-not $elevated) { + $leftovers = @($syncRuleName, $discoveryRuleName) | Where-Object { Test-RuleExists $netsh $_ } + if ($leftovers.Count -gt 0) { + Write-Output "ADE left its Windows Firewall rule(s) in place because the uninstaller is not running as Administrator: $($leftovers -join '; '). Remove them from Windows Defender Firewall > Inbound Rules, or re-run the uninstaller elevated." + exit 0 + } + Write-Output "No ADE Windows Firewall rules to remove." + exit 0 + } + $failures = [Collections.Generic.List[string]]::new() + foreach ($ruleName in @($syncRuleName, $discoveryRuleName)) { + $removal = Remove-Rule $netsh $ruleName + if ($removal.ExitCode -ne 0 -and (Test-RuleExists $netsh $ruleName)) { + $failures.Add("$ruleName ($($removal.Output))") + } + } + if ($failures.Count -gt 0) { + Write-Output "ADE could not remove Windows Firewall rule(s): $($failures -join '; ')" + exit 1 + } + Write-Output "Removed the ADE Windows Firewall rules." + exit 0 +} + +if (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) { + throw "The packaged ADE install is incomplete: missing $appExe" +} + +if (-not $elevated) { + Write-Output "Skipped the ADE Windows Firewall rules: a per-user ADE install does not run as Administrator, and Windows has no per-user firewall rule store. Windows will ask once, the first time you use LAN sync on a network." + exit 0 +} + +# Narrowest scope that still lets a phone on the same wifi reach the sync host: +# this executable only, inbound only, and only the sync port range plus mDNS. +# Public networks are deliberately excluded - being reachable on an untrusted +# network is a decision the user should make at the Windows prompt, not one the +# installer makes for them. +# +# No `remoteip=` narrowing on purpose. Windows stops raising the notification +# once ANY rule exists for a program, so a remote-address filter that misses a +# path ADE actually uses (a tailnet peer on 100.64.0.0/10, an IPv6 client, a +# phone routed from another subnet) would turn a visible prompt into a silent +# block, which is strictly worse than the bug this fixes. The profile filter is +# the scope limiter. +$ruleSpecs = @( + @{ + Name = $syncRuleName + Arguments = @( + "protocol=TCP", + "localport=$SYNC_HOST_MIN_PORT-$SYNC_HOST_MAX_PORT" + ) + Description = "Lets devices on your local network reach the ADE sync host." + }, + @{ + Name = $discoveryRuleName + Arguments = @( + "protocol=UDP", + "localport=$MDNS_PORT" + ) + Description = "Lets devices on your local network discover this ADE host over mDNS." + } +) + +$applied = [Collections.Generic.List[string]]::new() +foreach ($spec in $ruleSpecs) { + Remove-Rule $netsh $spec.Name | Out-Null + $addArguments = @( + "advfirewall", "firewall", "add", "rule", + "name=$($spec.Name)", + "dir=in", + "action=allow", + "program=$appExe", + "profile=private,domain", + "enable=yes", + "description=$($spec.Description)" + ) + $spec.Arguments + $result = Invoke-Netsh $netsh $addArguments + if ($result.ExitCode -ne 0) { + # Undo the partial rule set so a failed install never leaves a half-open + # inbound allowance behind. + foreach ($cleanupName in @($syncRuleName, $discoveryRuleName)) { + Remove-Rule $netsh $cleanupName | Out-Null + } + Write-Output "ADE could not add the Windows Firewall rule '$($spec.Name)': $($result.Output)" + exit 1 + } + $applied.Add($spec.Name) +} + +Write-Output "Added the ADE Windows Firewall rules: $($applied -join '; ')" +exit 0 diff --git a/apps/desktop/scripts/windows-install-setup.ps1 b/apps/desktop/scripts/windows-install-setup.ps1 new file mode 100644 index 000000000..e5c1a2b1c --- /dev/null +++ b/apps/desktop/scripts/windows-install-setup.ps1 @@ -0,0 +1,166 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$InstallDir, + [Parameter(Mandatory = $true)] + [string]$AppExecutableName, + [ValidateSet("stable", "beta", "alpha")] + [string]$PackageChannel = "stable" +) + +$ErrorActionPreference = "Stop" + +function Restore-ProcessValue([string]$Name, [string]$Value, [bool]$WasPresent) { + [Environment]::SetEnvironmentVariable($Name, $(if ($WasPresent) { $Value } else { $null }), "Process") +} + +function Get-ShortSha256([string]$Value) { + $sha = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Value)))).Replace("-", "").Substring(0, 12).ToLowerInvariant() + } finally { + $sha.Dispose() + } +} + +function Stop-BrainProcessPreservingStartup([string]$HomePath, [string]$Channel) { + $serviceName = if ($Channel -eq "stable") { "com.ade.runtime" } else { "com.ade.runtime.$Channel" } + $launcherPath = Join-Path $HomePath "runtime\brain-service-$(Get-ShortSha256 $serviceName).ps1" + foreach ($process in @(Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { + $_.Name -match '^powershell(?:\.exe)?$' -and + ([string]$_.CommandLine).IndexOf($launcherPath, [StringComparison]::OrdinalIgnoreCase) -ge 0 + })) { + & taskkill.exe /PID ([string]$process.ProcessId) /T /F | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Could not restore the previous stopped ADE brain state." + } + } +} + +$resolvedInstallDir = [IO.Path]::GetFullPath($InstallDir).TrimEnd("\") +$normalizedExecutableName = [IO.Path]::GetFileName($AppExecutableName) +if (-not [string]::Equals($normalizedExecutableName, $AppExecutableName, [StringComparison]::Ordinal) -or + -not $normalizedExecutableName.EndsWith(".exe", [StringComparison]::OrdinalIgnoreCase)) { + throw "The installer did not provide a valid ADE executable name." +} + +$appExe = Join-Path $resolvedInstallDir $normalizedExecutableName +$cliRoot = Join-Path $resolvedInstallDir "resources\ade-cli" +$cliName = if ($PackageChannel -eq "stable") { "ade.cmd" } else { "ade-$PackageChannel.cmd" } +$cliWrapper = Join-Path $cliRoot "bin\$cliName" +$pathInstaller = Join-Path $cliRoot "install-path.cmd" +$cleanupScript = Join-Path $cliRoot "windows-uninstall-cleanup.ps1" +$cliTarget = if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + throw "LOCALAPPDATA is unavailable; ADE cannot safely install its terminal command." +} else { + Join-Path $env:LOCALAPPDATA "ADE\bin\$cliName" +} +$cliTargetExisted = Test-Path -LiteralPath $cliTarget -PathType Leaf +$previousCliTargetBytes = if ($cliTargetExisted) { [IO.File]::ReadAllBytes($cliTarget) } else { $null } +$previousUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + +foreach ($required in @($appExe, $cliWrapper, $pathInstaller, $cleanupScript, (Join-Path $cliRoot "cli.cjs"))) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw "The packaged ADE install is incomplete: missing $required" + } +} + +$saved = @{} +foreach ($name in @( + "ADE_BIN", "ADE_HOME", "ADE_PACKAGE_CHANNEL", "ADE_DESKTOP_APP_NAME", + "ADE_DISABLE_CLI_AUTO_INSTALL", "ELECTRON_RUN_AS_NODE", "NODE_PATH" +)) { + $saved[$name] = @{ Present = Test-Path "Env:$name"; Value = [Environment]::GetEnvironmentVariable($name, "Process") } +} + +$previousServiceInstalled = $false +$previousServiceRunning = $false +$serviceStateKnown = $false + +try { + $env:ADE_BIN = $cliWrapper + $env:ADE_PACKAGE_CHANNEL = $PackageChannel + $env:ADE_DESKTOP_APP_NAME = [IO.Path]::GetFileNameWithoutExtension($normalizedExecutableName) + $env:ADE_DISABLE_CLI_AUTO_INSTALL = "1" + $env:ELECTRON_RUN_AS_NODE = "1" + $homeName = if ($PackageChannel -eq "stable") { ".ade" } else { ".ade-$PackageChannel" } + $env:ADE_HOME = Join-Path ([Environment]::GetFolderPath("UserProfile")) $homeName + $resourcesDir = Join-Path $resolvedInstallDir "resources" + $nodePathEntries = @( + (Join-Path $resourcesDir "app.asar.unpacked\node_modules") + (Join-Path $resourcesDir "app.asar\node_modules") + if (-not [string]::IsNullOrWhiteSpace($saved.NODE_PATH.Value)) { $saved.NODE_PATH.Value } + ) + $env:NODE_PATH = $nodePathEntries -join [IO.Path]::PathSeparator + + $serviceStatusJson = (& $cliWrapper serve --service-status --json 2>$null | Out-String) + if ($LASTEXITCODE -ne 0) { + throw "The ADE per-user brain startup state could not be read before setup." + } + try { + $serviceStatus = $serviceStatusJson | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "The ADE per-user brain startup state was invalid before setup." + } + if ($serviceStatus.installed -isnot [bool]) { + throw "The ADE per-user brain startup state was incomplete before setup." + } + if ($serviceStatus.running -isnot [bool]) { + throw "The ADE per-user brain running state was incomplete before setup." + } + $previousServiceInstalled = $serviceStatus.installed + $previousServiceRunning = $serviceStatus.running + $serviceStateKnown = $true + + & $pathInstaller $cliTarget + if ($LASTEXITCODE -ne 0) { + throw "The ADE terminal command installer exited with code $LASTEXITCODE." + } + & $cliWrapper serve --install-service + if ($LASTEXITCODE -ne 0) { + throw "The ADE per-user brain startup installer exited with code $LASTEXITCODE." + } +} catch { + $setupError = $_ + $rollbackErrors = [Collections.Generic.List[string]]::new() + if ($serviceStateKnown) { + $rollbackServiceFlag = if ($previousServiceInstalled) { "--install-service" } else { "--uninstall-service" } + & $cliWrapper serve $rollbackServiceFlag 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + $rollbackErrors.Add("could not restore the previous brain startup state (exit $LASTEXITCODE)") + } elseif ($previousServiceInstalled -and -not $previousServiceRunning) { + try { + Stop-BrainProcessPreservingStartup $env:ADE_HOME $PackageChannel + } catch { + $rollbackErrors.Add($_.Exception.Message) + } + } + } + try { + if ($cliTargetExisted) { + $cliTargetParent = Split-Path $cliTarget -Parent + New-Item -ItemType Directory -Path $cliTargetParent -Force | Out-Null + [IO.File]::WriteAllBytes($cliTarget, $previousCliTargetBytes) + } else { + Remove-Item -LiteralPath $cliTarget -Force -ErrorAction SilentlyContinue + } + } catch { + $rollbackErrors.Add("could not restore the previous terminal shim: $($_.Exception.Message)") + } + try { + $currentUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + if (-not [string]::Equals($currentUserPath, $previousUserPath, [StringComparison]::Ordinal)) { + [Environment]::SetEnvironmentVariable("Path", $previousUserPath, "User") + } + } catch { + $rollbackErrors.Add("could not restore the previous user PATH: $($_.Exception.Message)") + } + if ($rollbackErrors.Count -gt 0) { + throw "ADE setup failed ($($setupError.Exception.Message)) and compensation failed: $($rollbackErrors -join '; ')" + } + throw $setupError +} finally { + foreach ($name in $saved.Keys) { + Restore-ProcessValue $name $saved[$name].Value $saved[$name].Present + } +} diff --git a/apps/desktop/scripts/windows-installed-product-smoke.ps1 b/apps/desktop/scripts/windows-installed-product-smoke.ps1 new file mode 100644 index 000000000..0267a589b --- /dev/null +++ b/apps/desktop/scripts/windows-installed-product-smoke.ps1 @@ -0,0 +1,228 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$InstallerPath, + [ValidateSet("stable", "beta", "alpha")] + [string]$PackageChannel = "stable", + [string]$ProductName = $(if ($PackageChannel -eq "stable") { "ADE" } else { "ADE " + (Get-Culture).TextInfo.ToTitleCase($PackageChannel) }), + [string]$CompanionInstallerPath = "" +) + +$ErrorActionPreference = "Stop" +$installer = [IO.Path]::GetFullPath($InstallerPath) +if (-not (Test-Path -LiteralPath $installer -PathType Leaf)) { + throw "Windows installed-product smoke is missing installer: $installer" +} +if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + throw "LOCALAPPDATA is required for the per-user installed-product smoke." +} +if (-not [string]::IsNullOrWhiteSpace($CompanionInstallerPath) -and $PackageChannel -ne "stable") { + throw "A companion installer may only be tested from the Stable lifecycle." +} + +$installDir = Join-Path $env:LOCALAPPDATA "Programs\$ProductName" +$appExe = Join-Path $installDir "$ProductName.exe" +$uninstaller = Join-Path $installDir "Uninstall $ProductName.exe" +$cliName = if ($PackageChannel -eq "stable") { "ade.cmd" } else { "ade-$PackageChannel.cmd" } +$cliShim = Join-Path $env:LOCALAPPDATA "ADE\bin\$cliName" +$cliPathDir = Split-Path $cliShim -Parent +$runKey = "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" +$uninstallRoot = "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Uninstall" + +function Invoke-Installer { + $process = Start-Process -FilePath $installer -ArgumentList @("/S") -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0) { throw "Installer exited with code $($process.ExitCode)." } +} + +function Invoke-Uninstaller([bool]$BestEffort = $false) { + if (-not (Test-Path -LiteralPath $uninstaller -PathType Leaf)) { + if ($BestEffort) { return } + throw "Installed product is missing its uninstaller: $uninstaller" + } + try { + $process = Start-Process -FilePath $uninstaller -ArgumentList @("/S") -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0) { throw "Uninstaller exited with code $($process.ExitCode)." } + } catch { + if (-not $BestEffort) { throw } + Write-Warning "Best-effort installed-product cleanup failed: $($_.Exception.Message)" + } +} + +function Stop-LaunchedApp { + if ($launchedApp -and -not $launchedApp.HasExited) { + & taskkill.exe /PID $launchedApp.Id /T /F | Out-Null + } + $script:launchedApp = $null +} + +function Stop-InstalledProductProcesses { + $normalizedAppExe = [IO.Path]::GetFullPath($appExe) + $channelAdeHome = Join-Path ([Environment]::GetFolderPath("UserProfile")) $homeName + $launcherPrefix = Join-Path $channelAdeHome "runtime\brain-service-" + $allProcesses = @(Get-CimInstance Win32_Process -ErrorAction Stop) + $supervisors = @($allProcesses | Where-Object { + $_.Name -match '^powershell(?:\.exe)?$' -and + ([string]$_.CommandLine).IndexOf($launcherPrefix, [StringComparison]::OrdinalIgnoreCase) -ge 0 + }) + foreach ($supervisor in $supervisors) { + & taskkill.exe /PID ([string]$supervisor.ProcessId) /T /F | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Could not stop channel-owned ADE supervisor $($supervisor.ProcessId) before repair." + } + } + $processes = @($allProcesses | Where-Object { + try { + -not [string]::IsNullOrWhiteSpace($_.ExecutablePath) -and + [string]::Equals( + [IO.Path]::GetFullPath([string]$_.ExecutablePath), + $normalizedAppExe, + [StringComparison]::OrdinalIgnoreCase + ) + } catch { $false } + }) + foreach ($process in $processes) { + & taskkill.exe /PID ([string]$process.ProcessId) /T /F | Out-Null + if ($LASTEXITCODE -ne 0) { + $remaining = Get-CimInstance Win32_Process -Filter "ProcessId = $($process.ProcessId)" -ErrorAction SilentlyContinue + if ($remaining) { + throw "Could not stop channel-owned ADE process $($process.ProcessId) before repair." + } + } + } +} + +function Assert-InstalledProduct([string]$Phase) { + foreach ($required in @( + $appExe, + $uninstaller, + $cliShim, + (Join-Path $installDir "resources\ade-cli\cli.cjs"), + (Join-Path $installDir "resources\ade-cli\windows-install-setup.ps1"), + (Join-Path $installDir "resources\ade-cli\windows-uninstall-cleanup.ps1") + )) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw "$Phase is missing installed product file: $required" + } + } + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $pathEntries = @($userPath -split ";" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if (-not ($pathEntries | Where-Object { + try { [IO.Path]::GetFullPath($_).TrimEnd("\") -eq [IO.Path]::GetFullPath($cliPathDir).TrimEnd("\") } catch { $false } + })) { + throw "$Phase did not add the ADE terminal directory to the user PATH." + } + $startupValues = if (Test-Path -LiteralPath $runKey) { + (Get-Item -LiteralPath $runKey).GetValueNames() | Where-Object { $_ -like "ADE Runtime (*" } + } else { @() } + $ownedStartup = @($startupValues | Where-Object { + [string](Get-Item -LiteralPath $runKey).GetValue($_) -like "*$homeName*brain-service-*.ps1*" + }) + if ($ownedStartup.Count -ne 1) { + throw "$Phase expected one channel-owned ADE startup entry, found $($ownedStartup.Count)." + } + $displayEntries = @(Get-ChildItem -LiteralPath $uninstallRoot -ErrorAction SilentlyContinue | ForEach-Object { + Get-ItemProperty -LiteralPath $_.PSPath -ErrorAction SilentlyContinue + } | Where-Object { $_.DisplayName -eq $ProductName }) + if ($displayEntries.Count -ne 1 -or $displayEntries[0].DisplayVersion -notmatch '^\d+\.\d+\.\d+') { + throw "$Phase did not register exactly one installed $ProductName product with a version." + } + $mdAssociation = Get-Item -LiteralPath "Registry::HKEY_CURRENT_USER\Software\Classes\.md\OpenWithProgids" -ErrorAction SilentlyContinue + $associationNames = if ($mdAssociation) { @($mdAssociation.GetValueNames()) } else { @() } + if (-not ($associationNames | Where-Object { $_ -eq $fileClass })) { + throw "$Phase did not register the .md file association." + } +} + +function Assert-UninstalledProduct { + if (Test-Path -LiteralPath $appExe -PathType Leaf) { throw "Uninstall left the ADE executable behind." } + if (Test-Path -LiteralPath $cliShim -PathType Leaf) { throw "Uninstall left its owned ADE terminal shim behind." } + + $remainingShims = @(Get-ChildItem -LiteralPath $cliPathDir -Filter "ade*.cmd" -File -ErrorAction SilentlyContinue) + $remainingPath = [Environment]::GetEnvironmentVariable("Path", "User") + $pathStillRegistered = [bool](@($remainingPath -split ";") | Where-Object { + try { [IO.Path]::GetFullPath($_).TrimEnd("\") -eq [IO.Path]::GetFullPath($cliPathDir).TrimEnd("\") } catch { $false } + }) + if ($remainingShims.Count -gt 0 -and -not $pathStillRegistered) { + throw "Uninstall removed the shared ADE terminal PATH entry while another channel shim remains." + } + if ($remainingShims.Count -eq 0 -and $pathStillRegistered) { + throw "Uninstall left an unowned ADE terminal PATH entry behind." + } + + $remainingStartup = if (Test-Path -LiteralPath $runKey) { + @((Get-Item -LiteralPath $runKey).GetValueNames() | Where-Object { + [string](Get-Item -LiteralPath $runKey).GetValue($_) -like "*$homeName*brain-service-*.ps1*" + }) + } else { @() } + if ($remainingStartup.Count -ne 0) { throw "Uninstall left its ADE brain startup entry behind." } + if ($PackageChannel -eq "stable" -and (Test-Path -LiteralPath "Registry::HKEY_CURRENT_USER\Software\Classes\ade")) { + throw "Uninstall left its owned ade:// protocol registration behind." + } + $remainingMdAssociation = Get-Item -LiteralPath "Registry::HKEY_CURRENT_USER\Software\Classes\.md\OpenWithProgids" -ErrorAction SilentlyContinue + if ($remainingMdAssociation -and @($remainingMdAssociation.GetValueNames()) -contains $fileClass) { + throw "Uninstall left its owned .md file association behind." + } +} + +$homeName = if ($PackageChannel -eq "stable") { ".ade" } else { ".ade-$PackageChannel" } +$fileClass = if ($PackageChannel -eq "stable") { "com.ade.desktop.files" } else { "com.ade.desktop.$PackageChannel.files" } +$launchedApp = $null +$uninstallVerified = $false +try { + Invoke-Installer + Assert-InstalledProduct "fresh install" + + Remove-Item -LiteralPath $cliShim -Force + Invoke-Installer + Assert-InstalledProduct "repair reinstall" + + Stop-InstalledProductProcesses + Remove-Item -LiteralPath $appExe -Force + Invoke-Installer + Assert-InstalledProduct "missing-executable repair" + + Invoke-Installer + Assert-InstalledProduct "idempotent reinstall" + + if ($PackageChannel -eq "stable") { + $launchedApp = Start-Process -FilePath $appExe -ArgumentList @("ade://work") -PassThru + $protocolCommand = $null + $deadline = [DateTime]::UtcNow.AddSeconds(20) + while ([DateTime]::UtcNow -lt $deadline) { + try { + $protocolCommand = [string](Get-Item -LiteralPath "Registry::HKEY_CURRENT_USER\Software\Classes\ade\shell\open\command" -ErrorAction Stop).GetValue("") + } catch {} + if ($protocolCommand -like "*$appExe*") { break } + Start-Sleep -Milliseconds 250 + } + if ($protocolCommand -notlike "*$appExe*") { + throw "Installed Stable ADE did not claim the ade:// deep-link protocol." + } + } + + if (-not [string]::IsNullOrWhiteSpace($CompanionInstallerPath)) { + $companion = [IO.Path]::GetFullPath($CompanionInstallerPath) + if (-not (Test-Path -LiteralPath $companion -PathType Leaf)) { + throw "Windows side-by-side smoke is missing the Beta installer: $companion" + } + & $PSCommandPath -InstallerPath $companion -PackageChannel beta -ProductName "ADE Beta" + Assert-InstalledProduct "Stable after Beta side-by-side lifecycle" + $mdDefault = [string](Get-Item -LiteralPath "Registry::HKEY_CURRENT_USER\Software\Classes\.md" -ErrorAction Stop).GetValue("") + if ($mdDefault -ne $fileClass) { + throw "Beta uninstall did not restore Stable as the .md default association." + } + } + + Stop-LaunchedApp + Invoke-Uninstaller + Assert-UninstalledProduct + $uninstallVerified = $true +} finally { + Stop-LaunchedApp + if (-not $uninstallVerified) { + Invoke-Uninstaller -BestEffort $true + } +} + +$sideBySideResult = if ([string]::IsNullOrWhiteSpace($CompanionInstallerPath)) { "" } else { ", Stable/Beta side-by-side ownership" } +Write-Output "Windows installed-product smoke passed: install, repair, reinstall, PATH, startup, deep links, file association$sideBySideResult, uninstall." diff --git a/apps/desktop/scripts/windows-package-identity.mjs b/apps/desktop/scripts/windows-package-identity.mjs new file mode 100644 index 000000000..17322cacc --- /dev/null +++ b/apps/desktop/scripts/windows-package-identity.mjs @@ -0,0 +1,46 @@ +export function resolveWindowsPackageIdentity(rawChannel = "") { + const rawValue = String(rawChannel ?? "").trim(); + const packageChannel = rawValue.toLowerCase() || "stable"; + if (!new Set(["stable", "beta", "alpha"]).has(packageChannel)) { + throw new Error(`Unsupported ADE_PACKAGE_CHANNEL '${rawValue}'. Expected stable, beta, or alpha.`); + } + const channelLabel = packageChannel === "stable" + ? "" + : `${packageChannel[0].toUpperCase()}${packageChannel.slice(1)}`; + const productName = channelLabel ? `ADE ${channelLabel}` : "ADE"; + const appId = packageChannel === "stable" + ? "com.ade.desktop" + : `com.ade.desktop.${packageChannel}`; + return { + packageChannel, + productName, + // Release asset names must never contain a space. electron-builder writes + // the installer with the raw product name but rewrites latest.yml's + // url/path to a space-free "safe" name for GitHub, and the release workflow + // uploads the on-disk file through `gh release upload`, where GitHub + // normalizes disallowed characters again. A space-free artifact base name + // keeps the built file, latest.yml, and the published asset identical. + artifactBaseName: channelLabel ? `ADE-${channelLabel}` : "ADE", + appId, + executableName: `${productName}.exe`, + fileClass: `${appId}.files`, + }; +} + +export function isGithubSafeAssetName(name) { + return /^[0-9A-Za-z._-]+$/.test(String(name ?? "")); +} + +export function windowsInstallerArtifactName(identity) { + // electron-builder macros are expanded by electron-builder, not here. + // eslint-disable-next-line no-template-curly-in-string + return `${identity.artifactBaseName}-\${version}-win-\${arch}.\${ext}`; +} + +export function windowsInstallerPattern(identity) { + const escapedBaseName = identity.artifactBaseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // The version segment always starts with a digit, so the Stable pattern can + // never also match a channel build such as ADE-Beta-1.2.3-win-x64.exe when a + // single release directory holds both installers. + return new RegExp(`^${escapedBaseName}-\\d.*-win-x64\\.exe$`); +} diff --git a/apps/desktop/scripts/windows-proof-indexes.mjs b/apps/desktop/scripts/windows-proof-indexes.mjs new file mode 100644 index 000000000..6ccf15e27 --- /dev/null +++ b/apps/desktop/scripts/windows-proof-indexes.mjs @@ -0,0 +1,514 @@ +export const INVENTORY_SCHEMA_VERSION = "ade.windows-proof-scenarios/v1"; +export const PROVENANCE_SCHEMA_VERSION = "ade.windows-source-provenance/v1"; +export const EVIDENCE_KINDS = ["gui", "log", "db", "process", "ipc", "network"]; + +const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/; +const SAFE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const PROVIDERS = ["claude", "codex", "cursor", "opencode", "droid"]; +const PROVIDER_STATES = [ + "authenticated", "unauthenticated", "fresh", "resume", "recovery-metadata", + "recovery-instructions", "redaction", +]; +const REQUIRED_ACCEPTANCE_GATES = new Map([ + ["shell-conpty-matrix", [ + "powershell-5-1", "powershell-7", "cmd", "git-bash", "conpty-unicode", + "conpty-metacharacters", "conpty-resize", "conpty-ctrl-c", "conpty-cancel", + "descendant-cleanup", "crash-restore", + ]], + ["provider-lifecycle-matrix", PROVIDERS.flatMap((provider) => ( + PROVIDER_STATES.map((state) => `${provider}-${state}`) + ))], + ["standalone-cli-brain", [ + "ade-win32-x64", "install", "start", "status", "doctor", "update", + "remote-bootstrap", "openssh-prerequisite", "damaged-install-recovery", + ]], + ["brain-host-lifecycle", [ + "desktop-closed", "brain-crash", "brain-restart", "login", "logout", "reboot", + "repair", "reinstall", "uninstall", + ]], + ["account-oauth-directory", [ + "default-browser-callback", "encrypted-persistence", "reauthentication", "sign-out", + "existing-machine-discovery", + ]], + ["cross-machine-directions", [ + "windows-session-to-macos", "windows-session-to-physical-ios", + "windows-session-to-hosted-web", "macos-session-to-windows", + "windows-client-to-macos-linux-runtime", "macos-linux-client-to-windows-runtime", + ]], + ["transport-streaming-reconnect", [ + "lan-firewall", "tailscale", "relay", "reconnect", "terminal-streaming", + "chat-streaming", "remote-commands", + ]], + ["signed-updater-proof", [ + "signed-n-to-n-plus-one", "rfc3161-timestamp", "publisher-identity", + "tamper-rejection", "relaunch", "brain-recovery", "data-preservation", + "smartscreen-observation", + ]], + ["unchanged-release-paths", [ + "macos-desktop", "macos-runtime", "linux-runtime", "web", "relay", "ios", + ]], + ["draft-assets-and-website", [ + "installer", "blockmap", "latest-yml", "checksums", "update-metadata", + "website-link-disabled", "website-link-correct", + ]], +]); +const POST_DRAFT_GATE_IDS = new Set(["draft-assets-and-website"]); +const REQUIRED_GATE_SCENARIO_IDS = new Map([ + ["shell-conpty-matrix", "explicit-shell-conpty-matrix"], + ["provider-lifecycle-matrix", "explicit-provider-lifecycle-matrix"], + ["standalone-cli-brain", "standalone-cli-brain-lifecycle"], + ["brain-host-lifecycle", "brain-host-lifecycle-explicit"], + ["account-oauth-directory", "account-oauth-directory-explicit"], + ["cross-machine-directions", "cross-machine-directions-explicit"], + ["transport-streaming-reconnect", "transport-streaming-reconnect-explicit"], + ["signed-updater-proof", "signed-updater-proof-explicit"], + ["unchanged-release-paths", "unchanged-release-paths-explicit"], + ["draft-assets-and-website", "draft-assets-website-explicit"], +]); +const SOURCE_999_COMMITS = [ + "9b1ffc367d71b387ba0d49850d37827a1703cfce", + "236330ad9095d6e30f2068572faec5b53ae7c1b2", + "615eda5ec4a8b81c1e99f02030817dad5880878d", + "0eae1517c9b5caa67a6eb5a12ce9ddff5f50392a", + "0cfcc1c2c0f9d1c7023c2a64052463647899ca6f", + "de52986c188be8b6bc8f3f6de5c486fd53ada436", + "fb3bfe95a9b008006e54ae97b8878e9dbb1c25e5", + "7cc22ca5273f60857e1c91a6bed885e3123087d4", + "24e47be41ad942f80f423eea9e67bab25218ac0d", +]; +const REBASED_999_COMMITS = new Map([ + [SOURCE_999_COMMITS[0], "a97f9fc6e9ed0bad68428e24e8ca5126e0d46bf1"], + [SOURCE_999_COMMITS[1], "cf9e8af77919ee5b78d3b62ccd2aec15c01d2ac8"], + [SOURCE_999_COMMITS[2], "c3ab7394d275d8fcadbf2fd248cb6f39461fa553"], + [SOURCE_999_COMMITS[3], "d924e34e05f7acd1bbe210ada735dc2b4027755e"], + [SOURCE_999_COMMITS[4], "0ac7ce522fab0cba6737e76f4091ce9f4a97d064"], + [SOURCE_999_COMMITS[5], "7f3fe926bfaf1a786aaa8aa044b0e9b6585b33b4"], + [SOURCE_999_COMMITS[6], "2d6d161a7784954769c99b032cfe8a1bbabde9d0"], + [SOURCE_999_COMMITS[7], "06591c12355d5d76a548be3a3ff6178a654910a9"], + [SOURCE_999_COMMITS[8], "fc7764dd4ecf27f2c95218abf1ce0a78488812df"], +]); + +function addError(errors, field, message) { + errors.push(`${field}: ${message}`); +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requireObject(errors, value, field) { + if (!isPlainObject(value)) { + addError(errors, field, "must be an object"); + return false; + } + return true; +} + +function rejectUnknownKeys(errors, value, field, allowedKeys) { + if (!isPlainObject(value)) return; + for (const key of Object.keys(value)) { + if (!allowedKeys.includes(key)) addError(errors, `${field}.${key}`, "is not part of the schema"); + } +} + +function requireString(errors, value, field, pattern = null) { + if (typeof value !== "string" || value.length === 0) { + addError(errors, field, "must be a non-empty string"); + return false; + } + if (pattern && !pattern.test(value)) { + addError(errors, field, "has an invalid format"); + return false; + } + return true; +} + +function requireStringArray(errors, value, field, { allowEmpty = false, pattern = null } = {}) { + if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) { + addError(errors, field, `must be ${allowEmpty ? "an" : "a non-empty"} array`); + return false; + } + const seen = new Set(); + value.forEach((item, index) => { + if (!requireString(errors, item, `${field}[${index}]`, pattern)) return; + if (seen.has(item)) addError(errors, `${field}[${index}]`, `duplicates ${JSON.stringify(item)}`); + seen.add(item); + }); + return true; +} + +function validateDimensionCoverage(errors, inventory, dimensionName, requiredValues) { + const covered = new Set(); + for (const scenario of inventory.scenarios ?? []) { + const values = scenario?.coverage?.[dimensionName]; + if (Array.isArray(values)) values.forEach((value) => covered.add(value)); + } + for (const value of requiredValues) { + if (!covered.has(value)) { + addError(errors, `dimensions.${dimensionName}`, `${JSON.stringify(value)} is not covered by any scenario`); + } + } +} + +export function validateInventory(inventory) { + const errors = []; + if (!requireObject(errors, inventory, "inventory")) return errors; + rejectUnknownKeys(errors, inventory, "inventory", [ + "schemaVersion", "dimensions", "requiredEvidenceKinds", "acceptanceGates", "scenarios", + ]); + if (inventory.schemaVersion !== INVENTORY_SCHEMA_VERSION) { + addError(errors, "schemaVersion", `must equal ${INVENTORY_SCHEMA_VERSION}`); + } + const dimensions = inventory.dimensions; + const requiredDimensions = { + operatingSystems: ["windows-10-22h2-x64", "windows-11-x64"], + shells: ["powershell-5-1", "powershell-7", "cmd", "git-bash"], + providers: ["claude", "codex", "cursor", "droid", "opencode"], + clients: ["windows-desktop", "ade-code", "hosted-web", "ios", "desktop-peer"], + routes: ["lan", "tailscale", "relay"], + journeys: ["account-oauth", "account-directory", "signed-n-to-n-plus-one-update", "windows-regressions"], + }; + if (requireObject(errors, dimensions, "dimensions")) { + rejectUnknownKeys(errors, dimensions, "dimensions", Object.keys(requiredDimensions)); + for (const [name, required] of Object.entries(requiredDimensions)) { + const values = dimensions[name]; + requireStringArray(errors, values, `dimensions.${name}`, { pattern: SAFE_ID_PATTERN }); + if (!Array.isArray(values)) continue; + for (const requiredValue of required) { + if (!values.includes(requiredValue)) { + addError(errors, `dimensions.${name}`, `must include ${JSON.stringify(requiredValue)}`); + } + } + if (name === "shells") { + for (const value of values) { + if (!required.includes(value)) { + addError(errors, "dimensions.shells", `contains unknown shell ${JSON.stringify(value)}`); + } + } + } + validateDimensionCoverage(errors, inventory, name, values); + } + } + requireStringArray(errors, inventory.requiredEvidenceKinds, "requiredEvidenceKinds", { + pattern: SAFE_ID_PATTERN, + }); + if (Array.isArray(inventory.requiredEvidenceKinds)) { + for (const kind of EVIDENCE_KINDS) { + if (!inventory.requiredEvidenceKinds.includes(kind)) { + addError(errors, "requiredEvidenceKinds", `must include ${kind}`); + } + } + } + const declaredGateIds = new Set(); + if (!Array.isArray(inventory.acceptanceGates)) { + addError(errors, "acceptanceGates", "must be an array"); + } else { + inventory.acceptanceGates.forEach((gate, index) => { + const field = `acceptanceGates[${index}]`; + if (!requireObject(errors, gate, field)) return; + rejectUnknownKeys(errors, gate, field, ["id", "stage", "requirements"]); + if (requireString(errors, gate.id, `${field}.id`, SAFE_ID_PATTERN)) { + if (declaredGateIds.has(gate.id)) addError(errors, `${field}.id`, "must be unique"); + declaredGateIds.add(gate.id); + } + const expected = REQUIRED_ACCEPTANCE_GATES.get(gate.id); + const expectedStage = POST_DRAFT_GATE_IDS.has(gate.id) ? "post-draft" : "pre-tag"; + if (gate.stage !== expectedStage) { + addError(errors, `${field}.stage`, `must equal ${expectedStage}`); + } + requireStringArray(errors, gate.requirements, `${field}.requirements`, { pattern: SAFE_ID_PATTERN }); + if (!expected) { + addError(errors, `${field}.id`, "is not a required Windows acceptance gate"); + } else if (Array.isArray(gate.requirements)) { + for (const requirement of expected) { + if (!gate.requirements.includes(requirement)) { + addError(errors, `${field}.requirements`, `must include ${requirement}`); + } + } + for (const requirement of gate.requirements) { + if (!expected.includes(requirement)) { + addError(errors, `${field}.requirements`, `contains unknown ${requirement}`); + } + } + } + }); + } + for (const gateId of REQUIRED_ACCEPTANCE_GATES.keys()) { + if (!declaredGateIds.has(gateId)) addError(errors, "acceptanceGates", `is missing ${gateId}`); + } + if (!Array.isArray(inventory.scenarios) || inventory.scenarios.length === 0) { + addError(errors, "scenarios", "must be a non-empty array"); + return errors; + } + const scenarioIds = new Set(); + inventory.scenarios.forEach((scenario, index) => { + const field = `scenarios[${index}]`; + if (!requireObject(errors, scenario, field)) return; + rejectUnknownKeys(errors, scenario, field, [ + "id", "title", "hosts", "coverage", "acceptanceGateIds", "requiredEvidenceKinds", + "acceptanceRequirementIds", "passConditions", "dependencies", + ]); + if (requireString(errors, scenario.id, `${field}.id`, SAFE_ID_PATTERN)) { + if (scenarioIds.has(scenario.id)) addError(errors, `${field}.id`, "must be unique"); + scenarioIds.add(scenario.id); + } + requireString(errors, scenario.title, `${field}.title`); + if (scenario.acceptanceGateIds !== undefined) { + if (requireStringArray(errors, scenario.acceptanceGateIds, `${field}.acceptanceGateIds`, { + pattern: SAFE_ID_PATTERN, + })) { + for (const gateId of scenario.acceptanceGateIds) { + if (!declaredGateIds.has(gateId)) { + addError(errors, `${field}.acceptanceGateIds`, `${gateId} is not declared`); + } + } + const allowedRequirements = new Set(scenario.acceptanceGateIds.flatMap((gateId) => ( + REQUIRED_ACCEPTANCE_GATES.get(gateId) ?? [] + ))); + if (requireStringArray(errors, scenario.acceptanceRequirementIds, `${field}.acceptanceRequirementIds`, { + pattern: SAFE_ID_PATTERN, + })) { + for (const requirement of allowedRequirements) { + if (!scenario.acceptanceRequirementIds.includes(requirement)) { + addError(errors, `${field}.acceptanceRequirementIds`, `must include ${requirement}`); + } + } + for (const requirement of scenario.acceptanceRequirementIds) { + if (!allowedRequirements.has(requirement)) { + addError(errors, `${field}.acceptanceRequirementIds`, `contains unknown ${requirement}`); + } + } + } + } + } else if (scenario.acceptanceRequirementIds !== undefined) { + addError(errors, `${field}.acceptanceRequirementIds`, "requires acceptanceGateIds"); + } + requireStringArray(errors, scenario.hosts, `${field}.hosts`, { pattern: SAFE_ID_PATTERN }); + if (Array.isArray(scenario.hosts) && isPlainObject(dimensions)) { + const operatingSystems = Array.isArray(dimensions.operatingSystems) + ? dimensions.operatingSystems + : []; + for (const host of scenario.hosts) { + if (!operatingSystems.includes(host)) { + addError(errors, `${field}.hosts`, `${JSON.stringify(host)} is not a declared Windows host`); + } + } + } + if (requireObject(errors, scenario.coverage, `${field}.coverage`) && isPlainObject(dimensions)) { + rejectUnknownKeys(errors, scenario.coverage, `${field}.coverage`, Object.keys(dimensions)); + for (const dimensionName of Object.keys(dimensions)) { + const values = scenario.coverage[dimensionName] ?? []; + requireStringArray(errors, values, `${field}.coverage.${dimensionName}`, { + allowEmpty: true, + pattern: SAFE_ID_PATTERN, + }); + if (Array.isArray(values)) { + const declaredValues = Array.isArray(dimensions[dimensionName]) + ? dimensions[dimensionName] + : []; + for (const value of values) { + if (!declaredValues.includes(value)) { + addError(errors, `${field}.coverage.${dimensionName}`, `${JSON.stringify(value)} is not declared`); + } + } + } + } + } + if (requireStringArray(errors, scenario.requiredEvidenceKinds, `${field}.requiredEvidenceKinds`, { + pattern: SAFE_ID_PATTERN, + })) { + if (scenario.requiredEvidenceKinds.length < 2) { + addError(errors, `${field}.requiredEvidenceKinds`, "must require at least two independent signal kinds"); + } + for (const kind of scenario.requiredEvidenceKinds) { + if (!EVIDENCE_KINDS.includes(kind)) { + addError(errors, `${field}.requiredEvidenceKinds`, `${JSON.stringify(kind)} is not supported`); + } + } + } + requireStringArray(errors, scenario.passConditions, `${field}.passConditions`); + requireStringArray(errors, scenario.dependencies, `${field}.dependencies`, { allowEmpty: true }); + }); + for (const gateId of REQUIRED_ACCEPTANCE_GATES.keys()) { + const expectedScenarioId = REQUIRED_GATE_SCENARIO_IDS.get(gateId); + const boundScenarios = inventory.scenarios.filter((scenario) => ( + Array.isArray(scenario?.acceptanceGateIds) && scenario.acceptanceGateIds.includes(gateId) + )); + if (!boundScenarios.some((scenario) => scenario.id === expectedScenarioId)) { + addError(errors, "scenarios", `${gateId} must be bound by ${expectedScenarioId}`); + } + if (boundScenarios.some((scenario) => scenario.id !== expectedScenarioId)) { + addError(errors, "scenarios", `${gateId} may only be bound by ${expectedScenarioId}`); + } + } + return errors; +} + +export function validateProvenance(provenance) { + const errors = []; + if (!requireObject(errors, provenance, "provenance")) return errors; + rejectUnknownKeys(errors, provenance, "provenance", [ + "schemaVersion", "sourcePullRequest", "commitMappings", "stackLayers", + "sourceReviewDispositions", "requiredCommitTrailers", + ]); + if (provenance.schemaVersion !== PROVENANCE_SCHEMA_VERSION) { + addError(errors, "schemaVersion", `must equal ${PROVENANCE_SCHEMA_VERSION}`); + } + const source = provenance.sourcePullRequest; + if (requireObject(errors, source, "sourcePullRequest")) { + rejectUnknownKeys(errors, source, "sourcePullRequest", [ + "baseRepository", "headRepository", "number", "url", "authorName", "authorLogin", "headSha", + ]); + if (source.number !== 999) addError(errors, "sourcePullRequest.number", "must equal 999"); + if (source.headRepository !== "nsxdavid/ADE") addError(errors, "sourcePullRequest.headRepository", "must credit nsxdavid/ADE"); + if (source.baseRepository !== "arul28/ADE") addError(errors, "sourcePullRequest.baseRepository", "must identify arul28/ADE"); + if (source.authorName !== "David Whatley" || source.authorLogin !== "nsxdavid") { + addError(errors, "sourcePullRequest.author", "must credit David Whatley (nsxdavid)"); + } + if (source.url !== "https://github.com/arul28/ADE/pull/999") { + addError(errors, "sourcePullRequest.url", "must identify the canonical pull request"); + } + requireString(errors, source.headSha, "sourcePullRequest.headSha", COMMIT_SHA_PATTERN); + if (source.headSha !== SOURCE_999_COMMITS.at(-1)) { + addError(errors, "sourcePullRequest.headSha", "must equal the reviewed #999 head commit"); + } + } + if (!Array.isArray(provenance.commitMappings) || provenance.commitMappings.length === 0) { + addError(errors, "commitMappings", "must be a non-empty array"); + return errors; + } + const sourceShas = new Set(); + const rebasedShas = new Set(); + provenance.commitMappings.forEach((mapping, index) => { + const field = `commitMappings[${index}]`; + if (!requireObject(errors, mapping, field)) return; + rejectUnknownKeys(errors, mapping, field, ["sourceSha", "rebasedSha", "subject"]); + if (requireString(errors, mapping.sourceSha, `${field}.sourceSha`, COMMIT_SHA_PATTERN)) { + if (sourceShas.has(mapping.sourceSha)) addError(errors, `${field}.sourceSha`, "must be unique"); + sourceShas.add(mapping.sourceSha); + } + if (requireString(errors, mapping.rebasedSha, `${field}.rebasedSha`, COMMIT_SHA_PATTERN)) { + if (rebasedShas.has(mapping.rebasedSha)) addError(errors, `${field}.rebasedSha`, "must be unique"); + rebasedShas.add(mapping.rebasedSha); + const expected = REBASED_999_COMMITS.get(mapping.sourceSha); + if (expected && mapping.rebasedSha !== expected) { + addError(errors, `${field}.rebasedSha`, `must equal the reviewed rebased commit ${expected}`); + } + } + requireString(errors, mapping.subject, `${field}.subject`); + }); + if (provenance.commitMappings.length !== SOURCE_999_COMMITS.length) { + addError(errors, "commitMappings", `must contain all ${SOURCE_999_COMMITS.length} source commits from #999`); + } + for (const sourceSha of SOURCE_999_COMMITS) { + if (!sourceShas.has(sourceSha)) addError(errors, "commitMappings", `is missing #999 source commit ${sourceSha}`); + } + if (source?.headSha && !sourceShas.has(source.headSha)) { + addError(errors, "sourcePullRequest.headSha", "must appear in commitMappings"); + } + if (!Array.isArray(provenance.stackLayers) || provenance.stackLayers.length === 0) { + addError(errors, "stackLayers", "must be a non-empty array"); + return errors; + } + const coveredSourceShas = new Set(); + const layerIds = new Set(); + provenance.stackLayers.forEach((layer, index) => { + const field = `stackLayers[${index}]`; + if (!requireObject(errors, layer, field)) return; + rejectUnknownKeys(errors, layer, field, ["id", "purpose", "sourceCommits"]); + if (requireString(errors, layer.id, `${field}.id`, SAFE_ID_PATTERN)) { + if (layerIds.has(layer.id)) addError(errors, `${field}.id`, "must be unique"); + layerIds.add(layer.id); + } + requireString(errors, layer.purpose, `${field}.purpose`); + if (requireStringArray(errors, layer.sourceCommits, `${field}.sourceCommits`, { + pattern: COMMIT_SHA_PATTERN, + })) { + layer.sourceCommits.forEach((sha) => { + coveredSourceShas.add(sha); + if (!sourceShas.has(sha)) addError(errors, `${field}.sourceCommits`, `${sha} has no commit mapping`); + }); + } + }); + for (const sourceSha of sourceShas) { + if (!coveredSourceShas.has(sourceSha)) { + addError(errors, "stackLayers", `source commit ${sourceSha} is not attributed to any layer`); + } + } + const dispositionSpecs = new Map([ + ["codex-p2-windows-supervisor-registration", { + allowedKeys: [ + "id", "source", "status", "finding", "disposition", "currentRegistration", + "launcher", "pidRecord", "readinessRecord", "scheduledTasks", "stackLayers", + ], + exactFields: { + source: "original-999-codex-inline-p2", + status: "resolved", + currentRegistration: "hkcu-run", + launcher: "hidden-powershell-supervisor", + pidRecord: "supervisor-runtime-pids", + readinessRecord: "initialized-runtime-ipc", + scheduledTasks: "legacy-cleanup-only", + }, + stackLayers: ["windows-runtime-and-ipc", "windows-proof-and-support"], + }], + ["codex-p2-windows-desktop-app-channel", { + allowedKeys: [ + "id", "source", "status", "finding", "disposition", "requestedAppName", + "currentExecutableMatch", "fallbackPolicy", "stackLayers", + ], + exactFields: { + source: "original-999-codex-inline-p2", + status: "resolved", + requestedAppName: "channel-qualified-executable", + currentExecutableMatch: "exact-request-only", + fallbackPolicy: "search-requested-channel", + }, + stackLayers: ["windows-providers-and-clients", "windows-proof-and-support"], + }], + ]); + const dispositions = provenance.sourceReviewDispositions; + if (!Array.isArray(dispositions) || dispositions.length !== dispositionSpecs.size) { + addError(errors, "sourceReviewDispositions", "must contain both original #999 Codex P2 dispositions"); + } + const dispositionIds = new Set(); + if (Array.isArray(dispositions)) dispositions.forEach((disposition, index) => { + const field = `sourceReviewDispositions[${index}]`; + if (!requireObject(errors, disposition, field)) return; + const spec = dispositionSpecs.get(disposition.id); + if (!spec) { + addError(errors, `${field}.id`, "is not a reviewed original #999 Codex P2 disposition"); + return; + } + if (dispositionIds.has(disposition.id)) addError(errors, `${field}.id`, "must be unique"); + dispositionIds.add(disposition.id); + rejectUnknownKeys(errors, disposition, field, spec.allowedKeys); + for (const [name, expected] of Object.entries(spec.exactFields)) { + if (disposition[name] !== expected) addError(errors, `${field}.${name}`, `must equal ${expected}`); + } + requireString(errors, disposition.finding, `${field}.finding`); + requireString(errors, disposition.disposition, `${field}.disposition`); + if (requireStringArray(errors, disposition.stackLayers, `${field}.stackLayers`, { + pattern: SAFE_ID_PATTERN, + })) { + for (const requiredLayer of spec.stackLayers) { + if (!disposition.stackLayers.includes(requiredLayer)) { + addError(errors, `${field}.stackLayers`, `must include ${requiredLayer}`); + } + } + } + }); + for (const id of dispositionSpecs.keys()) { + if (!dispositionIds.has(id)) addError(errors, "sourceReviewDispositions", `is missing ${id}`); + } + if (requireObject(errors, provenance.requiredCommitTrailers, "requiredCommitTrailers")) { + rejectUnknownKeys(errors, provenance.requiredCommitTrailers, "requiredCommitTrailers", ["coAuthor", "basedOn"]); + } + if (provenance.requiredCommitTrailers?.coAuthor !== "David Whatley ") { + addError(errors, "requiredCommitTrailers.coAuthor", "must preserve David Whatley's commit credit"); + } + if (provenance.requiredCommitTrailers?.basedOn !== "nsxdavid/ADE#999") { + addError(errors, "requiredCommitTrailers.basedOn", "must equal nsxdavid/ADE#999"); + } + return errors; +} diff --git a/apps/desktop/scripts/windows-proof-manifest.mjs b/apps/desktop/scripts/windows-proof-manifest.mjs new file mode 100644 index 000000000..06fe75ab2 --- /dev/null +++ b/apps/desktop/scripts/windows-proof-manifest.mjs @@ -0,0 +1,952 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + EVIDENCE_KINDS, + validateInventory, + validateProvenance, +} from "./windows-proof-indexes.mjs"; + +export { EVIDENCE_KINDS, validateInventory, validateProvenance } from "./windows-proof-indexes.mjs"; + +export const MANIFEST_SCHEMA_VERSION = "ade.windows-proof/v1"; +const RESULT_STATES = ["pending", "pass", "fail", "blocked"]; +const APPROVAL_STATES = ["proof_pending", "proof_complete", "approved"]; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/; +const RELEASE_TAG_PATTERN = /^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; +const SAFE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const SAFE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const SAFE_HOST_ALIAS_PATTERN = /^win(?:10|11)-[a-z0-9][a-z0-9-]*$/; +const MAX_EVIDENCE_BYTES = 20 * 1024 * 1024; +const GUI_EVIDENCE_EXTENSIONS = new Set([".jpeg", ".jpg", ".png", ".webp"]); +const STRUCTURED_EVIDENCE_EXTENSIONS = new Set([".csv", ".json", ".jsonl", ".log", ".txt"]); +const AUTHORIZED_RUNTIME_FILES = [ + "ade-darwin-arm64", + "ade-darwin-arm64.native.tar.gz", + "ade-darwin-x64", + "ade-darwin-x64.native.tar.gz", + "ade-linux-arm64", + "ade-linux-arm64.native.tar.gz", + "ade-linux-x64", + "ade-linux-x64.native.tar.gz", + "ade-win32-x64.exe", + "ade-win32-x64.native.tar.gz", +]; +const AUTHORIZED_RUNTIME_CHECKSUM_FILES = [ + "install.sh", + "install.ps1", + ...AUTHORIZED_RUNTIME_FILES, +]; +const REDACTED_VALUE_PATTERNS = [ + { pattern: /\bBearer\s+[A-Za-z0-9._~+\/-]+=*/i, label: "bearer credential" }, + { pattern: /\b(?:gh[pousr]_|github_pat_|sk-|xox[baprs]-)[A-Za-z0-9_-]{8,}/i, label: "token-shaped value" }, + { pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{12,}\b/, label: "AWS access key" }, + { pattern: /[A-Z]:\\Users\\[^\\\s]+/i, label: "Windows user profile path" }, + { pattern: /\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b/, label: "email address" }, + { pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/, label: "IP address" }, +]; +const FORBIDDEN_DATA_KEYS = new Set([ + "accountname", + "authorization", + "certificate", + "certificatesubject", + "credential", + "email", + "hostname", + "ip", + "ipaddress", + "machinename", + "password", + "privatekey", + "publisher", + "publishersubject", + "secret", + "token", + "username", +]); + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const defaultRepoRoot = path.resolve(scriptDir, "..", "..", ".."); +const defaultInventoryPath = path.join( + defaultRepoRoot, + "docs", + "development", + "windows-full-system-scenarios.json", +); +const defaultProvenancePath = path.join( + defaultRepoRoot, + "docs", + "development", + "windows-source-provenance.json", +); + +function addError(errors, field, message) { + errors.push(`${field}: ${message}`); +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requireObject(errors, value, field) { + if (!isPlainObject(value)) { + addError(errors, field, "must be an object"); + return false; + } + return true; +} + +function rejectUnknownKeys(errors, value, field, allowedKeys) { + if (!isPlainObject(value)) return; + for (const key of Object.keys(value)) { + if (!allowedKeys.includes(key)) addError(errors, `${field}.${key}`, "is not part of the schema"); + } +} + +function requireString(errors, value, field, pattern = null) { + if (typeof value !== "string" || value.length === 0) { + addError(errors, field, "must be a non-empty string"); + return false; + } + if (pattern && !pattern.test(value)) { + addError(errors, field, "has an invalid format"); + return false; + } + return true; +} + +function requireBoolean(errors, value, field) { + if (typeof value !== "boolean") { + addError(errors, field, "must be a boolean"); + return false; + } + return true; +} + +function requirePositiveInteger(errors, value, field) { + if (!Number.isSafeInteger(value) || value <= 0) { + addError(errors, field, "must be a positive integer"); + return false; + } + return true; +} + +function requireIsoTimestamp(errors, value, field) { + if (!requireString(errors, value, field)) return false; + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) + || Number.isNaN(Date.parse(value))) { + addError(errors, field, "must be an ISO-8601 UTC timestamp"); + return false; + } + return true; +} + +function requireStringArray(errors, value, field, { allowEmpty = false, pattern = null } = {}) { + if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) { + addError(errors, field, `must be ${allowEmpty ? "an" : "a non-empty"} array`); + return false; + } + const seen = new Set(); + value.forEach((item, index) => { + if (!requireString(errors, item, `${field}[${index}]`, pattern)) return; + if (seen.has(item)) addError(errors, `${field}[${index}]`, `duplicates ${JSON.stringify(item)}`); + seen.add(item); + }); + return true; +} + +function isSafeRelativePath(value) { + if (typeof value !== "string" || value.length === 0) return false; + if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) return false; + if (value.includes("\\")) return false; + const segments = value.split("/"); + return segments.every((segment) => segment !== "" && segment !== "." && segment !== ".."); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function sha256Buffer(value) { + return createHash("sha256").update(value).digest("hex"); +} + +export function sha256File(filePath) { + const hash = createHash("sha256"); + const fd = fs.openSync(filePath, "r"); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { + while (true) { + const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + hash.update(buffer.subarray(0, bytesRead)); + } + } finally { + fs.closeSync(fd); + } + return hash.digest("hex"); +} + +function sha256JsonFile(filePath) { + return sha256Buffer(fs.readFileSync(filePath)); +} + +function validateNoSensitiveData(value, errors, field = "manifest") { + if (Array.isArray(value)) { + value.forEach((item, index) => validateNoSensitiveData(item, errors, `${field}[${index}]`)); + return; + } + if (isPlainObject(value)) { + for (const [key, item] of Object.entries(value)) { + if (FORBIDDEN_DATA_KEYS.has(key.toLowerCase())) { + addError(errors, `${field}.${key}`, "raw secret or personal-identifier fields are forbidden"); + } + validateNoSensitiveData(item, errors, `${field}.${key}`); + } + return; + } + if (typeof value !== "string") return; + for (const check of REDACTED_VALUE_PATTERNS) { + if (check.pattern.test(value)) { + addError(errors, field, `contains a ${check.label}; store a digest or redacted alias instead`); + } + } +} + +function expectedReleaseArtifacts(version) { + return [ + ["installer", `ADE-${version}-win-x64.exe`], + ["blockmap", `ADE-${version}-win-x64.exe.blockmap`], + ["update-manifest", "latest.yml"], + ["standalone-runtime", "ade-win32-x64.exe"], + ["standalone-native-archive", "ade-win32-x64.native.tar.gz"], + ["standalone-installer", "install.ps1"], + ["runtime-checksums", "SHA256SUMS"], + ]; +} + +function findReleaseArtifacts(releaseDir, version) { + return expectedReleaseArtifacts(version).map(([role, file]) => { + const filePath = path.join(releaseDir, file); + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + throw new Error(`Expected release artifact ${file}.`); + } + return { + role, + file, + sha256: sha256File(filePath), + sizeBytes: fs.statSync(filePath).size, + }; + }); +} + +export function createBuildManifest({ + releaseDir, + targetSha, + releaseTag, + repository, + workflowName, + workflowRunId, + workflowRunAttempt, + workflowUrl, + inventoryPath = defaultInventoryPath, + provenancePath = defaultProvenancePath, + createdAt = new Date().toISOString(), +}) { + const inventory = readJson(inventoryPath); + const inventoryErrors = validateInventory(inventory); + if (inventoryErrors.length > 0) { + throw new Error(`Scenario inventory is invalid:\n${inventoryErrors.join("\n")}`); + } + const provenance = readJson(provenancePath); + const provenanceErrors = validateProvenance(provenance); + if (provenanceErrors.length > 0) { + throw new Error(`Source provenance is invalid:\n${provenanceErrors.join("\n")}`); + } + const releaseMatch = releaseTag.match(RELEASE_TAG_PATTERN); + if (!releaseMatch) throw new Error("releaseTag must look like v1.2.3."); + if (!COMMIT_SHA_PATTERN.test(targetSha)) throw new Error("targetSha must be a lowercase 40-character commit SHA."); + if (!SAFE_REPOSITORY_PATTERN.test(repository)) throw new Error("repository must look like owner/repo."); + if (!/^\d+$/.test(String(workflowRunId)) || Number(workflowRunId) <= 0) { + throw new Error("workflowRunId must be a positive integer."); + } + if (!Number.isSafeInteger(Number(workflowRunAttempt)) || Number(workflowRunAttempt) <= 0) { + throw new Error("workflowRunAttempt must be a positive integer."); + } + + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + createdAt, + release: { + repository, + version: releaseMatch[1], + tag: releaseTag, + targetSha, + architecture: "x64", + workflow: { + name: workflowName, + event: "workflow_dispatch", + runId: String(workflowRunId), + runAttempt: Number(workflowRunAttempt), + url: workflowUrl, + publish: false, + }, + }, + buildValidation: { + signedBuild: true, + validator: "apps/desktop/scripts/validate-win-artifacts.mjs", + authenticode: "passed", + rfc3161Timestamp: "passed", + signerConsistency: "passed", + publisherPin: "passed", + }, + artifacts: findReleaseArtifacts(releaseDir, releaseMatch[1]), + indexes: { + scenarioInventory: { + path: "docs/development/windows-full-system-scenarios.json", + sha256: sha256JsonFile(inventoryPath), + }, + sourceProvenance: { + path: "docs/development/windows-source-provenance.json", + sha256: sha256JsonFile(provenancePath), + }, + }, + releaseGates: { + signedBuildEnabled: true, + nonPublishingWorkflow: true, + githubReleaseCreated: false, + publicReleaseEnabled: false, + websiteReleaseReady: false, + }, + approval: { + state: "proof_pending", + approvedTargetSha: null, + approverRole: null, + approvedAt: null, + }, + scenarioResults: inventory.scenarios.map((scenario) => ({ + scenarioId: scenario.id, + status: "pending", + hostAliases: [], + evidenceIds: [], + blockerCode: null, + })), + evidence: [], + }; +} + +function validateArtifactEntries(errors, artifacts, version) { + if (!Array.isArray(artifacts) || artifacts.length !== 7) { + addError(errors, "artifacts", "must contain exactly the desktop and standalone Windows release entries"); + return; + } + const roles = new Set(); + artifacts.forEach((artifact, index) => { + const field = `artifacts[${index}]`; + if (!requireObject(errors, artifact, field)) return; + rejectUnknownKeys(errors, artifact, field, ["role", "file", "sha256", "sizeBytes"]); + if (requireString(errors, artifact.role, `${field}.role`, SAFE_ID_PATTERN)) roles.add(artifact.role); + if (!requireString(errors, artifact.file, `${field}.file`) || !isSafeRelativePath(artifact.file) || artifact.file.includes("/")) { + addError(errors, `${field}.file`, "must be a safe top-level relative file name"); + } + requireString(errors, artifact.sha256, `${field}.sha256`, SHA256_PATTERN); + requirePositiveInteger(errors, artifact.sizeBytes, `${field}.sizeBytes`); + }); + for (const [role] of expectedReleaseArtifacts(version)) { + if (!roles.has(role)) addError(errors, "artifacts", `is missing role ${role}`); + } + if (typeof version === "string" && version.length > 0) { + const expectedFiles = Object.fromEntries(expectedReleaseArtifacts(version)); + for (const artifact of artifacts) { + if (expectedFiles[artifact.role] && artifact.file !== expectedFiles[artifact.role]) { + addError(errors, `artifacts.${artifact.role}.file`, `must equal ${expectedFiles[artifact.role]}`); + } + } + } +} + +function validateRuntimeChecksums(errors, artifactRoot, artifacts) { + if (!artifactRoot || !Array.isArray(artifacts)) return; + const byRole = new Map(artifacts.map((artifact) => [artifact?.role, artifact])); + const checksumArtifact = byRole.get("runtime-checksums"); + if (!checksumArtifact || !isSafeRelativePath(checksumArtifact.file) || checksumArtifact.file.includes("/")) return; + const checksumPath = path.join(path.resolve(artifactRoot), checksumArtifact.file); + if (!fs.existsSync(checksumPath) || !fs.statSync(checksumPath).isFile()) return; + + const listed = new Map(); + for (const [index, line] of fs.readFileSync(checksumPath, "utf8").split(/\r?\n/).entries()) { + if (line.length === 0) continue; + const match = line.match(/^([0-9a-f]{64}) [ *](.+)$/); + if (!match) { + addError(errors, `artifacts.runtime-checksums.line${index + 1}`, "must use lowercase SHA-256 checksum format"); + continue; + } + const [, digest, file] = match; + if (!isSafeRelativePath(file) || file.includes("/")) { + addError(errors, `artifacts.runtime-checksums.line${index + 1}`, "must name a safe top-level relative file"); + continue; + } + if (listed.has(file)) { + addError(errors, "artifacts.runtime-checksums", `contains duplicate entry ${file}`); + continue; + } + listed.set(file, digest); + } + + const authorizedChecksums = new Set(AUTHORIZED_RUNTIME_CHECKSUM_FILES); + for (const file of listed.keys()) { + if (!authorizedChecksums.has(file)) { + addError(errors, "artifacts.runtime-checksums", `contains unauthorized entry ${file}`); + } + } + for (const file of AUTHORIZED_RUNTIME_CHECKSUM_FILES) { + if (!listed.has(file)) { + addError(errors, "artifacts.runtime-checksums", `is missing authorized runtime file ${file}`); + continue; + } + const runtimePath = path.join(path.resolve(artifactRoot), file); + if (!fs.existsSync(runtimePath) || !fs.statSync(runtimePath).isFile()) { + addError(errors, "artifactRoot", `is missing checksummed runtime file ${file}`); + } else if (sha256File(runtimePath) !== listed.get(file)) { + addError(errors, "artifacts.runtime-checksums", `does not match runtime file ${file}`); + } + } + + for (const role of ["standalone-runtime", "standalone-native-archive", "standalone-installer"]) { + const artifact = byRole.get(role); + if (!artifact || typeof artifact.file !== "string" || typeof artifact.sha256 !== "string") continue; + if (!listed.has(artifact.file)) { + addError(errors, "artifacts.runtime-checksums", `is missing ${artifact.file}`); + } else if (listed.get(artifact.file) !== artifact.sha256) { + addError(errors, "artifacts.runtime-checksums", `does not bind the declared SHA-256 for ${artifact.file}`); + } + } + + const runtimeFiles = fs.readdirSync(path.resolve(artifactRoot), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.startsWith("ade-")) + .map((entry) => entry.name) + .sort(); + const expectedRuntimeFiles = [...AUTHORIZED_RUNTIME_FILES].sort(); + for (const file of runtimeFiles) { + if (!AUTHORIZED_RUNTIME_FILES.includes(file)) { + addError(errors, "artifactRoot", `contains unauthorized runtime file ${file}`); + } + } + for (const file of expectedRuntimeFiles) { + if (!runtimeFiles.includes(file)) { + addError(errors, "artifactRoot", `is missing authorized runtime file ${file}`); + } + } +} + +function verifyIndexedFile(errors, root, entry, field, { scanText = false } = {}) { + if (!root || !entry || !isSafeRelativePath(entry.path)) return; + const absoluteRoot = path.resolve(root); + const absolutePath = path.resolve(absoluteRoot, ...entry.path.split("/")); + const relative = path.relative(absoluteRoot, absolutePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + addError(errors, `${field}.path`, "escapes the supplied root"); + return; + } + if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) { + addError(errors, `${field}.path`, `does not exist under ${absoluteRoot}`); + return; + } + const stat = fs.statSync(absolutePath); + if (entry.sizeBytes !== stat.size) addError(errors, `${field}.sizeBytes`, `expected ${stat.size}`); + const digest = sha256File(absolutePath); + if (entry.sha256 !== digest) addError(errors, `${field}.sha256`, `does not match ${entry.path}`); + if (scanText && stat.size <= MAX_EVIDENCE_BYTES) { + validateNoSensitiveData(fs.readFileSync(absolutePath, "utf8"), errors, `${field}.content`); + } +} + +export function validateManifest(manifest, { + inventory, + provenance, + expectedSha, + expectedTag = null, + expectedWorkflowRunId = null, + phase = "build", + artifactRoot = null, + evidenceRoot = null, + inventoryPath = null, + provenancePath = null, +} = {}) { + const errors = []; + if (!requireObject(errors, manifest, "manifest")) return errors; + rejectUnknownKeys(errors, manifest, "manifest", [ + "schemaVersion", + "createdAt", + "release", + "buildValidation", + "artifacts", + "indexes", + "releaseGates", + "approval", + "scenarioResults", + "evidence", + ]); + if (manifest.schemaVersion !== MANIFEST_SCHEMA_VERSION) { + addError(errors, "schemaVersion", `must equal ${MANIFEST_SCHEMA_VERSION}`); + } + const expectedShaIsValid = requireString(errors, expectedSha, "expectedSha", COMMIT_SHA_PATTERN); + requireIsoTimestamp(errors, manifest.createdAt, "createdAt"); + if (!["build", "complete", "publication-readiness", "draft-readiness"].includes(phase)) { + addError(errors, "phase", "must be build, complete, publication-readiness, or draft-readiness"); + } + const release = manifest.release; + if (requireObject(errors, release, "release")) { + rejectUnknownKeys(errors, release, "release", [ + "repository", "version", "tag", "targetSha", "architecture", "workflow", + ]); + requireString(errors, release.repository, "release.repository", SAFE_REPOSITORY_PATTERN); + requireString(errors, release.version, "release.version"); + const tagValid = requireString(errors, release.tag, "release.tag", RELEASE_TAG_PATTERN); + if (tagValid && release.tag !== `v${release.version}`) addError(errors, "release.tag", "must match release.version"); + if (expectedTag && release.tag !== expectedTag) { + addError(errors, "release.tag", `must equal expected tag ${expectedTag}`); + } + requireString(errors, release.targetSha, "release.targetSha", COMMIT_SHA_PATTERN); + if (expectedShaIsValid && release.targetSha !== expectedSha) { + addError(errors, "release.targetSha", `must equal expected SHA ${expectedSha}`); + } + if (release.architecture !== "x64") addError(errors, "release.architecture", "must equal x64"); + const workflow = release.workflow; + if (requireObject(errors, workflow, "release.workflow")) { + rejectUnknownKeys(errors, workflow, "release.workflow", [ + "name", "event", "runId", "runAttempt", "url", "publish", + ]); + requireString(errors, workflow.name, "release.workflow.name"); + if (workflow.event !== "workflow_dispatch") addError(errors, "release.workflow.event", "must equal workflow_dispatch"); + requireString(errors, workflow.runId, "release.workflow.runId", /^\d+$/); + if (expectedWorkflowRunId && workflow.runId !== expectedWorkflowRunId) { + addError(errors, "release.workflow.runId", `must equal approved proof run ${expectedWorkflowRunId}`); + } + requirePositiveInteger(errors, workflow.runAttempt, "release.workflow.runAttempt"); + requireString(errors, workflow.url, "release.workflow.url", /^https:\/\/github\.com\//); + if (typeof workflow.url === "string" && typeof workflow.runId === "string" + && !workflow.url.endsWith(`/actions/runs/${workflow.runId}`)) { + addError(errors, "release.workflow.url", "must end with the declared workflow run id"); + } + if (workflow.publish !== false) addError(errors, "release.workflow.publish", "must be false"); + } + } + const buildValidation = manifest.buildValidation; + if (requireObject(errors, buildValidation, "buildValidation")) { + rejectUnknownKeys(errors, buildValidation, "buildValidation", [ + "signedBuild", + "validator", + "authenticode", + "rfc3161Timestamp", + "signerConsistency", + "publisherPin", + ]); + if (buildValidation.signedBuild !== true) addError(errors, "buildValidation.signedBuild", "must be true"); + if (buildValidation.validator !== "apps/desktop/scripts/validate-win-artifacts.mjs") { + addError(errors, "buildValidation.validator", "must name the canonical Windows artifact validator"); + } + for (const gate of ["authenticode", "rfc3161Timestamp", "signerConsistency", "publisherPin"]) { + if (buildValidation[gate] !== "passed") addError(errors, `buildValidation.${gate}`, "must equal passed"); + } + } + validateArtifactEntries(errors, manifest.artifacts, release?.version); + if (artifactRoot && Array.isArray(manifest.artifacts)) { + manifest.artifacts.forEach((artifact, index) => { + verifyIndexedFile(errors, artifactRoot, { ...artifact, path: artifact.file }, `artifacts[${index}]`); + }); + validateRuntimeChecksums(errors, artifactRoot, manifest.artifacts); + } else { + addError(errors, "artifactRoot", "is required so release files are independently re-hashed"); + } + + const indexes = manifest.indexes; + if (requireObject(errors, indexes, "indexes")) { + rejectUnknownKeys(errors, indexes, "indexes", ["scenarioInventory", "sourceProvenance"]); + for (const [name, expectedPath, suppliedPath] of [ + ["scenarioInventory", "docs/development/windows-full-system-scenarios.json", inventoryPath], + ["sourceProvenance", "docs/development/windows-source-provenance.json", provenancePath], + ]) { + const entry = indexes[name]; + if (requireObject(errors, entry, `indexes.${name}`)) { + rejectUnknownKeys(errors, entry, `indexes.${name}`, ["path", "sha256"]); + if (entry.path !== expectedPath) addError(errors, `indexes.${name}.path`, `must equal ${expectedPath}`); + requireString(errors, entry.sha256, `indexes.${name}.sha256`, SHA256_PATTERN); + if (suppliedPath && fs.existsSync(suppliedPath) && entry.sha256 !== sha256JsonFile(suppliedPath)) { + addError(errors, `indexes.${name}.sha256`, "does not match the supplied index file"); + } + } + } + } + const gates = manifest.releaseGates; + if (requireObject(errors, gates, "releaseGates")) { + rejectUnknownKeys(errors, gates, "releaseGates", [ + "signedBuildEnabled", + "nonPublishingWorkflow", + "githubReleaseCreated", + "publicReleaseEnabled", + "websiteReleaseReady", + ]); + if (gates.signedBuildEnabled !== true) addError(errors, "releaseGates.signedBuildEnabled", "must be true"); + if (gates.nonPublishingWorkflow !== true) addError(errors, "releaseGates.nonPublishingWorkflow", "must be true"); + for (const gate of ["githubReleaseCreated", "publicReleaseEnabled", "websiteReleaseReady"]) { + if (gates[gate] !== false) addError(errors, `releaseGates.${gate}`, "must remain false during proof validation"); + } + } + const approval = manifest.approval; + if (requireObject(errors, approval, "approval")) { + rejectUnknownKeys(errors, approval, "approval", [ + "state", "approvedTargetSha", "approverRole", "approvedAt", + ]); + if (!APPROVAL_STATES.includes(approval.state)) addError(errors, "approval.state", "is unsupported"); + if (phase === "build" && approval.state !== "proof_pending") { + addError(errors, "approval.state", "must be proof_pending during build validation"); + } + if (phase === "complete" && approval.state !== "proof_complete") { + addError(errors, "approval.state", "must equal proof_complete"); + } + if (["build", "complete"].includes(phase)) { + for (const field of ["approvedTargetSha", "approverRole", "approvedAt"]) { + if (approval[field] !== null) addError(errors, `approval.${field}`, `must be null during ${phase} validation`); + } + } + if (["publication-readiness", "draft-readiness"].includes(phase)) { + if (approval.state !== "approved") addError(errors, "approval.state", "must equal approved"); + if (approval.approvedTargetSha !== release?.targetSha) { + addError(errors, "approval.approvedTargetSha", "must equal release.targetSha"); + } + if (approval.approverRole !== "windows-release-maintainer") { + addError(errors, "approval.approverRole", "must equal windows-release-maintainer"); + } + requireIsoTimestamp(errors, approval.approvedAt, "approval.approvedAt"); + } + } + + const inventoryErrors = inventory ? validateInventory(inventory) : ["inventory: is required"]; + inventoryErrors.forEach((error) => errors.push(`inventory.${error}`)); + const provenanceErrors = provenance ? validateProvenance(provenance) : ["provenance: is required"]; + provenanceErrors.forEach((error) => errors.push(`provenance.${error}`)); + const inventoryScenarios = Array.isArray(inventory?.scenarios) ? inventory.scenarios : []; + const scenariosById = new Map(inventoryScenarios.map((scenario) => [scenario?.id, scenario])); + const postDraftGateIds = new Set( + (Array.isArray(inventory?.acceptanceGates) ? inventory.acceptanceGates : []) + .filter((gate) => gate?.stage === "post-draft" && typeof gate.id === "string") + .map((gate) => gate.id), + ); + const scenarioIsPostDraft = (scenario) => ( + Array.isArray(scenario?.acceptanceGateIds) + && scenario.acceptanceGateIds.some((gateId) => postDraftGateIds.has(gateId)) + ); + const evidenceById = new Map(); + const evidencePaths = new Map(); + const evidenceDigests = new Map(); + const coveredEvidenceKinds = new Set(); + if (!Array.isArray(manifest.evidence)) { + addError(errors, "evidence", "must be an array"); + } else { + manifest.evidence.forEach((entry, index) => { + const field = `evidence[${index}]`; + if (!requireObject(errors, entry, field)) return; + rejectUnknownKeys(errors, entry, field, [ + "id", + "kind", + "collectionMethod", + "hostAlias", + "path", + "sha256", + "sizeBytes", + "collectedAt", + "scenarioIds", + "redaction", + ]); + if (requireString(errors, entry.id, `${field}.id`, SAFE_ID_PATTERN)) { + if (evidenceById.has(entry.id)) addError(errors, `${field}.id`, "must be unique"); + evidenceById.set(entry.id, entry); + } + if (!EVIDENCE_KINDS.includes(entry.kind)) addError(errors, `${field}.kind`, "is unsupported"); + else coveredEvidenceKinds.add(entry.kind); + requireString(errors, entry.collectionMethod, `${field}.collectionMethod`, SAFE_ID_PATTERN); + requireString(errors, entry.hostAlias, `${field}.hostAlias`, SAFE_HOST_ALIAS_PATTERN); + if (!requireString(errors, entry.path, `${field}.path`) || !isSafeRelativePath(entry.path)) { + addError(errors, `${field}.path`, "must be a safe relative path using forward slashes"); + } else { + if (evidencePaths.has(entry.path)) { + addError(errors, `${field}.path`, `duplicates ${evidencePaths.get(entry.path)}; independent evidence must use a unique file`); + } else { + evidencePaths.set(entry.path, field); + } + const extension = path.posix.extname(entry.path).toLowerCase(); + const allowedExtensions = entry.kind === "gui" + ? GUI_EVIDENCE_EXTENSIONS + : STRUCTURED_EVIDENCE_EXTENSIONS; + if (!allowedExtensions.has(extension)) { + addError(errors, `${field}.path`, `${entry.kind} evidence uses a forbidden file type`); + } + } + requireString(errors, entry.sha256, `${field}.sha256`, SHA256_PATTERN); + if (typeof entry.sha256 === "string" && SHA256_PATTERN.test(entry.sha256)) { + if (evidenceDigests.has(entry.sha256)) { + addError(errors, `${field}.sha256`, `duplicates ${evidenceDigests.get(entry.sha256)}; independent evidence must have unique content`); + } else { + evidenceDigests.set(entry.sha256, field); + } + } + requirePositiveInteger(errors, entry.sizeBytes, `${field}.sizeBytes`); + if (Number.isSafeInteger(entry.sizeBytes) && entry.sizeBytes > MAX_EVIDENCE_BYTES) { + addError(errors, `${field}.sizeBytes`, `must not exceed ${MAX_EVIDENCE_BYTES} bytes`); + } + requireIsoTimestamp(errors, entry.collectedAt, `${field}.collectedAt`); + requireStringArray(errors, entry.scenarioIds, `${field}.scenarioIds`, { pattern: SAFE_ID_PATTERN }); + if (Array.isArray(entry.scenarioIds)) { + entry.scenarioIds.forEach((id) => { + if (!scenariosById.has(id)) addError(errors, `${field}.scenarioIds`, `${id} is not in the inventory`); + }); + } + if (requireObject(errors, entry.redaction, `${field}.redaction`)) { + rejectUnknownKeys(errors, entry.redaction, `${field}.redaction`, [ + "status", "containsSecrets", "containsPersonalIdentifiers", + ]); + if (entry.redaction.status !== "redacted") addError(errors, `${field}.redaction.status`, "must equal redacted"); + if (entry.redaction.containsSecrets !== false) addError(errors, `${field}.redaction.containsSecrets`, "must be false"); + if (entry.redaction.containsPersonalIdentifiers !== false) { + addError(errors, `${field}.redaction.containsPersonalIdentifiers`, "must be false"); + } + } + if (evidenceRoot) verifyIndexedFile(errors, evidenceRoot, entry, field, { + scanText: entry.kind !== "gui", + }); + }); + } + if (phase !== "build" && !evidenceRoot) { + addError(errors, "evidenceRoot", "is required after build validation"); + } + + if (!Array.isArray(manifest.scenarioResults)) { + addError(errors, "scenarioResults", "must be an array"); + } else { + const resultsById = new Map(); + manifest.scenarioResults.forEach((result, index) => { + const field = `scenarioResults[${index}]`; + if (!requireObject(errors, result, field)) return; + rejectUnknownKeys(errors, result, field, [ + "scenarioId", "status", "hostAliases", "evidenceIds", "blockerCode", + ]); + if (requireString(errors, result.scenarioId, `${field}.scenarioId`, SAFE_ID_PATTERN)) { + if (resultsById.has(result.scenarioId)) addError(errors, `${field}.scenarioId`, "must be unique"); + resultsById.set(result.scenarioId, result); + } + if (!RESULT_STATES.includes(result.status)) addError(errors, `${field}.status`, "is unsupported"); + const resultScenario = scenariosById.get(result.scenarioId); + const requiresScenarioProof = phase === "draft-readiness" + || (phase !== "build" && !scenarioIsPostDraft(resultScenario)); + requireStringArray(errors, result.hostAliases, `${field}.hostAliases`, { + allowEmpty: !requiresScenarioProof, + pattern: SAFE_HOST_ALIAS_PATTERN, + }); + requireStringArray(errors, result.evidenceIds, `${field}.evidenceIds`, { + allowEmpty: !requiresScenarioProof, + pattern: SAFE_ID_PATTERN, + }); + if (result.blockerCode !== null && !SAFE_ID_PATTERN.test(result.blockerCode ?? "")) { + addError(errors, `${field}.blockerCode`, "must be null or a redacted code"); + } + if (result.status === "blocked" && result.blockerCode === null) { + addError(errors, `${field}.blockerCode`, "is required when status is blocked"); + } else if (result.status !== "blocked" && result.blockerCode !== null) { + addError(errors, `${field}.blockerCode`, "must be null unless status is blocked"); + } + }); + for (const [scenarioId, scenario] of scenariosById) { + const result = resultsById.get(scenarioId); + if (!result) { + addError(errors, "scenarioResults", `is missing ${scenarioId}`); + continue; + } + const requiresScenarioProof = phase === "draft-readiness" + || (phase !== "build" && !scenarioIsPostDraft(scenario)); + if (requiresScenarioProof && result.status !== "pass") { + addError(errors, `scenarioResults.${scenarioId}.status`, "must equal pass"); + } + if (requiresScenarioProof) { + const resultEvidenceIds = Array.isArray(result.evidenceIds) ? result.evidenceIds : []; + const resultHostAliases = Array.isArray(result.hostAliases) + ? result.hostAliases.filter((hostAlias) => typeof hostAlias === "string") + : []; + const scenarioHosts = Array.isArray(scenario?.hosts) + ? scenario.hosts.filter((host) => typeof host === "string") + : []; + const allowedHostPrefixes = new Set(scenarioHosts.map((host) => ( + host.startsWith("windows-10") ? "win10-" : "win11-" + ))); + for (const hostAlias of resultHostAliases) { + if (![...allowedHostPrefixes].some((prefix) => hostAlias.startsWith(prefix))) { + addError(errors, `scenarioResults.${scenarioId}.hostAliases`, `${hostAlias} does not match a declared scenario host`); + } + } + for (const prefix of allowedHostPrefixes) { + if (!resultHostAliases.some((hostAlias) => hostAlias.startsWith(prefix))) { + addError(errors, `scenarioResults.${scenarioId}.hostAliases`, `is missing required ${prefix.slice(0, -1)} host evidence`); + } + } + const linkedEvidence = resultEvidenceIds.map((id) => evidenceById.get(id)).filter(Boolean); + const linkedKinds = new Set(linkedEvidence.map((entry) => entry.kind)); + for (const prefix of allowedHostPrefixes) { + if (!linkedEvidence.some((entry) => ( + typeof entry.hostAlias === "string" && entry.hostAlias.startsWith(prefix) + ))) { + addError(errors, `scenarioResults.${scenarioId}.evidenceIds`, `is missing evidence collected on a required ${prefix.slice(0, -1)} host`); + } + } + for (const evidenceId of resultEvidenceIds) { + if (!evidenceById.has(evidenceId)) { + addError(errors, `scenarioResults.${scenarioId}.evidenceIds`, `${evidenceId} is not indexed`); + } else { + const linkedEntry = evidenceById.get(evidenceId); + if (!Array.isArray(linkedEntry.scenarioIds) || !linkedEntry.scenarioIds.includes(scenarioId)) { + addError(errors, `scenarioResults.${scenarioId}.evidenceIds`, `${evidenceId} does not link back to the scenario`); + } + if (!resultHostAliases.includes(linkedEntry.hostAlias)) { + addError(errors, `scenarioResults.${scenarioId}.hostAliases`, `does not include ${evidenceId}'s host alias`); + } + } + } + const requiredKinds = Array.isArray(scenario?.requiredEvidenceKinds) + ? scenario.requiredEvidenceKinds + : []; + for (const kind of requiredKinds) { + if (!linkedKinds.has(kind)) { + addError(errors, `scenarioResults.${scenarioId}.evidenceIds`, `is missing required ${kind} evidence`); + } + } + } + } + for (const scenarioId of resultsById.keys()) { + if (!scenariosById.has(scenarioId)) addError(errors, "scenarioResults", `contains unknown ${scenarioId}`); + } + for (const entry of evidenceById.values()) { + if (!Array.isArray(entry.scenarioIds)) continue; + for (const scenarioId of entry.scenarioIds) { + const result = resultsById.get(scenarioId); + if (result && (!Array.isArray(result.evidenceIds) || !result.evidenceIds.includes(entry.id))) { + addError(errors, `evidence.${entry.id}.scenarioIds`, `${scenarioId} does not link back to the evidence`); + } + } + } + } + if (phase !== "build") { + for (const kind of EVIDENCE_KINDS) { + if (!coveredEvidenceKinds.has(kind)) addError(errors, "evidence", `is missing global ${kind} coverage`); + } + } + validateNoSensitiveData(manifest, errors); + return errors; +} + +function parseArgs(argv) { + const [command, ...rest] = argv; + const options = {}; + for (let index = 0; index < rest.length; index += 1) { + const token = rest[index]; + if (!token.startsWith("--")) throw new Error(`Unexpected argument: ${token}`); + const key = token.slice(2); + const value = rest[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for --${key}`); + options[key] = value; + index += 1; + } + return { command, options }; +} + +function throwValidation(label, errors) { + if (errors.length > 0) throw new Error(`${label} failed:\n- ${errors.join("\n- ")}`); +} + +async function main(argv) { + const { command, options } = parseArgs(argv); + if (command === "create") { + for (const required of [ + "output", + "release-dir", + "target-sha", + "release-tag", + "repository", + "workflow-name", + "workflow-run-id", + "workflow-run-attempt", + "workflow-url", + ]) { + if (!options[required]) throw new Error(`create requires --${required}`); + } + const manifest = createBuildManifest({ + releaseDir: options["release-dir"], + targetSha: options["target-sha"].toLowerCase(), + releaseTag: options["release-tag"], + repository: options.repository, + workflowName: options["workflow-name"], + workflowRunId: options["workflow-run-id"], + workflowRunAttempt: options["workflow-run-attempt"], + workflowUrl: options["workflow-url"], + inventoryPath: options.inventory ?? defaultInventoryPath, + provenancePath: options.provenance ?? defaultProvenancePath, + }); + fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }); + fs.writeFileSync(options.output, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + process.stdout.write(`Created Windows proof manifest for ${manifest.release.targetSha}: ${options.output}\n`); + return; + } + if (command === "validate-inventory") { + const inventoryPath = options.inventory ?? defaultInventoryPath; + throwValidation("Windows scenario inventory validation", validateInventory(readJson(inventoryPath))); + process.stdout.write(`Validated Windows scenario inventory: ${inventoryPath}\n`); + return; + } + if (command === "validate-provenance") { + const provenancePath = options.provenance ?? defaultProvenancePath; + throwValidation("Windows source provenance validation", validateProvenance(readJson(provenancePath))); + process.stdout.write(`Validated Windows source provenance: ${provenancePath}\n`); + return; + } + if (command === "validate") { + if (!options.manifest) throw new Error("validate requires --manifest"); + const inventoryPath = options.inventory ?? defaultInventoryPath; + const provenancePath = options.provenance ?? defaultProvenancePath; + const phase = options.phase ?? "build"; + if (options["expected-sha"] && !COMMIT_SHA_PATTERN.test(options["expected-sha"])) { + throw new Error("--expected-sha must be a lowercase 40-character commit SHA."); + } + if (options["expected-tag"] && !RELEASE_TAG_PATTERN.test(options["expected-tag"])) { + throw new Error("--expected-tag must look like v1.2.3."); + } + if (options["expected-run-id"] && !/^\d+$/.test(options["expected-run-id"])) { + throw new Error("--expected-run-id must contain only decimal digits."); + } + const errors = validateManifest(readJson(options.manifest), { + inventory: readJson(inventoryPath), + provenance: readJson(provenancePath), + expectedSha: options["expected-sha"], + expectedTag: options["expected-tag"] ?? null, + expectedWorkflowRunId: options["expected-run-id"] ?? null, + phase, + artifactRoot: options["artifact-root"] ?? null, + evidenceRoot: options["evidence-root"] ?? null, + inventoryPath, + provenancePath, + }); + throwValidation(`Windows proof manifest ${phase} validation`, errors); + process.stdout.write(`Validated Windows proof manifest (${phase}): ${options.manifest}\n`); + return; + } + throw new Error( + "Usage: windows-proof-manifest.mjs [options]", + ); +} + +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null; +if (invokedPath === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/apps/desktop/scripts/windows-proof-manifest.test.mjs b/apps/desktop/scripts/windows-proof-manifest.test.mjs new file mode 100644 index 000000000..96f470708 --- /dev/null +++ b/apps/desktop/scripts/windows-proof-manifest.test.mjs @@ -0,0 +1,486 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + EVIDENCE_KINDS, + createBuildManifest, + sha256File, + validateInventory, + validateManifest, + validateProvenance, +} from "./windows-proof-manifest.mjs"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "..", "..", ".."); +const inventoryPath = path.join(repoRoot, "docs", "development", "windows-full-system-scenarios.json"); +const provenancePath = path.join(repoRoot, "docs", "development", "windows-source-provenance.json"); +const inventory = JSON.parse(fs.readFileSync(inventoryPath, "utf8")); +const provenance = JSON.parse(fs.readFileSync(provenancePath, "utf8")); +const targetSha = "0123456789abcdef0123456789abcdef01234567"; +const runtimeFiles = [ + "ade-darwin-arm64", + "ade-darwin-arm64.native.tar.gz", + "ade-darwin-x64", + "ade-darwin-x64.native.tar.gz", + "ade-linux-arm64", + "ade-linux-arm64.native.tar.gz", + "ade-linux-x64", + "ade-linux-x64.native.tar.gz", + "ade-win32-x64.exe", + "ade-win32-x64.native.tar.gz", +]; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function createFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-windows-proof-")); + const releaseDir = path.join(root, "release"); + const evidenceRoot = path.join(root, "evidence"); + fs.mkdirSync(releaseDir); + fs.mkdirSync(evidenceRoot); + fs.writeFileSync(path.join(releaseDir, "ADE-1.2.3-win-x64.exe"), "signed installer fixture"); + fs.writeFileSync(path.join(releaseDir, "ADE-1.2.3-win-x64.exe.blockmap"), "blockmap fixture"); + fs.writeFileSync(path.join(releaseDir, "latest.yml"), "version: 1.2.3\n"); + for (const file of runtimeFiles) { + fs.writeFileSync(path.join(releaseDir, file), `${file} fixture`); + } + fs.writeFileSync(path.join(releaseDir, "install.sh"), "#!/bin/sh\n"); + fs.writeFileSync(path.join(releaseDir, "install.ps1"), "Write-Output 'install fixture'\n"); + const checksummedFiles = ["install.sh", "install.ps1", ...runtimeFiles].sort(); + fs.writeFileSync(path.join(releaseDir, "SHA256SUMS"), checksummedFiles + .map((file) => `${sha256File(path.join(releaseDir, file))} ${file}`) + .join("\n") + "\n"); + const manifest = createBuildManifest({ + releaseDir, + targetSha, + releaseTag: "v1.2.3", + repository: "arul28/ADE", + workflowName: "Prepare release", + workflowRunId: "12345", + workflowRunAttempt: "1", + workflowUrl: "https://github.com/arul28/ADE/actions/runs/12345", + inventoryPath, + provenancePath, + createdAt: "2026-08-01T12:00:00.000Z", + }); + return { root, releaseDir, evidenceRoot, manifest }; +} + +function completeManifest(manifest, evidenceRoot) { + manifest.approval = { + state: "proof_complete", + approvedTargetSha: null, + approverRole: null, + approvedAt: null, + }; + let evidenceNumber = 0; + const evidence = []; + for (const result of manifest.scenarioResults) { + const scenario = inventory.scenarios.find((candidate) => candidate.id === result.scenarioId); + result.status = "pass"; + result.hostAliases = [...new Set(scenario.hosts.map((host) => ( + host.startsWith("windows-10") ? "win10-lab" : "win11-lab" + )))]; + result.blockerCode = null; + result.evidenceIds = []; + for (const hostAlias of result.hostAliases) { + for (const kind of scenario.requiredEvidenceKinds) { + evidenceNumber += 1; + const id = `proof-${String(evidenceNumber).padStart(4, "0")}`; + const relativePath = `${String(evidenceNumber).padStart(4, "0")}-${scenario.id}-${hostAlias}-${kind}.${kind === "gui" ? "png" : "txt"}`; + const absolutePath = path.join(evidenceRoot, ...relativePath.split("/")); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, `${scenario.id} ${hostAlias} ${kind} redacted proof\n`); + evidence.push({ + id, + kind, + collectionMethod: `${kind}-probe`, + hostAlias, + path: relativePath, + sha256: sha256File(absolutePath), + sizeBytes: fs.statSync(absolutePath).size, + collectedAt: "2026-08-01T13:00:00.000Z", + scenarioIds: [scenario.id], + redaction: { + status: "redacted", + containsSecrets: false, + containsPersonalIdentifiers: false, + }, + }); + result.evidenceIds.push(id); + } + } + } + manifest.evidence = evidence; + assert.deepEqual(new Set(evidence.map((entry) => entry.kind)), new Set(EVIDENCE_KINDS)); +} + +test("committed Windows scenario inventory covers the full declared matrix", () => { + assert.deepEqual(validateInventory(inventory), []); + assert.deepEqual(inventory.dimensions.shells, ["powershell-5-1", "powershell-7", "cmd", "git-bash"]); + const genericShell = clone(inventory); + genericShell.dimensions.shells[0] = "powershell"; + assert.ok(validateInventory(genericShell).some((error) => error.includes("unknown shell"))); + const incomplete = clone(inventory); + incomplete.acceptanceGates.find((gate) => gate.id === "shell-conpty-matrix").requirements.pop(); + assert.ok(validateInventory(incomplete).some((error) => error.includes("shell-conpty-matrix") || error.includes("crash-restore"))); + const weakened = clone(inventory); + const shellScenario = weakened.scenarios.find((scenario) => scenario.id === "explicit-shell-conpty-matrix"); + shellScenario.acceptanceRequirementIds.pop(); + shellScenario.id = "generic-shell-check"; + const weakenedErrors = validateInventory(weakened); + assert.ok(weakenedErrors.some((error) => error.includes("crash-restore"))); + assert.ok(weakenedErrors.some((error) => error.includes("explicit-shell-conpty-matrix"))); +}); + +test("committed provenance maps every #999 source commit into stack layers", () => { + assert.deepEqual(validateProvenance(provenance), []); + assert.equal(provenance.sourcePullRequest.authorName, "David Whatley"); + assert.equal(provenance.commitMappings.length, 9); + assert.deepEqual(new Set(provenance.sourceReviewDispositions.map(({ id }) => id)), new Set([ + "codex-p2-windows-desktop-app-channel", + "codex-p2-windows-supervisor-registration", + ])); + + const omittedDisposition = clone(provenance); + omittedDisposition.sourceReviewDispositions.pop(); + assert.ok(validateProvenance(omittedDisposition).some((error) => error.includes("both original #999 Codex P2 dispositions"))); + + const changedMapping = clone(provenance); + changedMapping.commitMappings[0].rebasedSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + assert.ok(validateProvenance(changedMapping).some((error) => error.includes("reviewed rebased commit"))); +}); + +test("build manifest is exact-SHA, non-publishing, and artifact-bound", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + assert.deepEqual(validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + expectedTag: "v1.2.3", + expectedWorkflowRunId: "12345", + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }), []); + assert.equal(fixture.manifest.release.workflow.publish, false); + assert.equal(fixture.manifest.releaseGates.publicReleaseEnabled, false); + assert.equal(fixture.manifest.releaseGates.websiteReleaseReady, false); + assert.deepEqual(fixture.manifest.artifacts.map(({ role }) => role), [ + "installer", + "blockmap", + "update-manifest", + "standalone-runtime", + "standalone-native-archive", + "standalone-installer", + "runtime-checksums", + ]); + + const wrongBuildIdentity = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + expectedTag: "v1.2.4", + expectedWorkflowRunId: "54321", + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }); + assert.ok(wrongBuildIdentity.some((error) => error.includes("expected tag v1.2.4"))); + assert.ok(wrongBuildIdentity.some((error) => error.includes("approved proof run 54321"))); +}); + +test("build validation rejects a different approved SHA or changed artifact", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + let errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("must equal expected SHA"))); + fs.appendFileSync(path.join(fixture.releaseDir, "ADE-1.2.3-win-x64.exe"), "changed"); + errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("artifacts[0].sha256"))); +}); + +test("build validation rejects a checksum manifest that does not bind standalone runtime bytes", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + const checksumPath = path.join(fixture.releaseDir, "SHA256SUMS"); + const changedChecksums = fs.readFileSync(checksumPath, "utf8").replace( + /^[0-9a-f]{64}( ade-win32-x64\.exe)$/m, + `${"a".repeat(64)}$1`, + ); + fs.writeFileSync(checksumPath, changedChecksums); + const checksumArtifact = fixture.manifest.artifacts.find(({ role }) => role === "runtime-checksums"); + checksumArtifact.sha256 = sha256File(checksumPath); + checksumArtifact.sizeBytes = fs.statSync(checksumPath).size; + + const errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("does not bind the declared SHA-256 for ade-win32-x64.exe"))); +}); + +test("build validation rejects unauthorized runtime files and checksum entries", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + const unexpectedFile = "ade-win32-arm64.exe"; + fs.writeFileSync(path.join(fixture.releaseDir, unexpectedFile), "unexpected runtime fixture"); + const checksumPath = path.join(fixture.releaseDir, "SHA256SUMS"); + fs.appendFileSync( + checksumPath, + `${sha256File(path.join(fixture.releaseDir, unexpectedFile))} ${unexpectedFile}\n`, + ); + const checksumArtifact = fixture.manifest.artifacts.find(({ role }) => role === "runtime-checksums"); + checksumArtifact.sha256 = sha256File(checksumPath); + checksumArtifact.sizeBytes = fs.statSync(checksumPath).size; + + const errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "build", + artifactRoot: fixture.releaseDir, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes(`contains unauthorized entry ${unexpectedFile}`))); + assert.ok(errors.some((error) => error.includes(`contains unauthorized runtime file ${unexpectedFile}`))); +}); + +test("complete validation re-hashes indexed evidence and enforces independent signals", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + completeManifest(fixture.manifest, fixture.evidenceRoot); + assert.deepEqual(validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }), []); + + const firstResult = fixture.manifest.scenarioResults[0]; + const removedId = firstResult.evidenceIds.shift(); + const errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + const removedKind = fixture.manifest.evidence.find((entry) => entry.id === removedId).kind; + assert.ok(errors.some((error) => error.includes(`missing required ${removedKind} evidence`))); +}); + +test("complete validation rejects duplicate, sensitive, or cross-host evidence", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + completeManifest(fixture.manifest, fixture.evidenceRoot); + + const duplicate = fixture.manifest.evidence[1]; + duplicate.path = fixture.manifest.evidence[0].path; + duplicate.sha256 = fixture.manifest.evidence[0].sha256; + duplicate.sizeBytes = fixture.manifest.evidence[0].sizeBytes; + const sensitiveEntry = fixture.manifest.evidence.find((entry) => entry !== duplicate && entry.kind !== "gui"); + const sensitivePath = path.join(fixture.evidenceRoot, ...sensitiveEntry.path.split("/")); + fs.writeFileSync(sensitivePath, "operator@example.com\n"); + sensitiveEntry.sha256 = sha256File(sensitivePath); + sensitiveEntry.sizeBytes = fs.statSync(sensitivePath).size; + const win11Result = fixture.manifest.scenarioResults.find((result) => { + const scenario = inventory.scenarios.find((candidate) => candidate.id === result.scenarioId); + return scenario.hosts.every((host) => host === "windows-11-x64"); + }); + win11Result.hostAliases = ["win10-lab"]; + + const errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("independent evidence must use a unique file"))); + assert.ok(errors.some((error) => error.includes("independent evidence must have unique content"))); + assert.ok(errors.some((error) => error.includes("email address"))); + assert.ok(errors.some((error) => error.includes("does not match a declared scenario host"))); + + const bothHosts = fixture.manifest.scenarioResults.find((result) => { + const scenario = inventory.scenarios.find((candidate) => candidate.id === result.scenarioId); + return scenario.hosts.includes("windows-10-22h2-x64") && scenario.hosts.includes("windows-11-x64"); + }); + bothHosts.hostAliases = ["win10-lab"]; + const missingOsErrors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(missingOsErrors.some((error) => error.includes("missing required win11 host evidence"))); +}); + +test("malformed evidence links return validation errors instead of throwing", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + completeManifest(fixture.manifest, fixture.evidenceRoot); + fixture.manifest.evidence[0].scenarioIds = "not-an-array"; + fixture.manifest.evidence[1].hostAlias = 7; + fixture.manifest.scenarioResults[0].evidenceIds = "not-an-array"; + fixture.manifest.scenarioResults[1].hostAliases = [7]; + const malformedInventory = clone(inventory); + malformedInventory.scenarios[0].hosts = [7]; + malformedInventory.dimensions.operatingSystems = {}; + const malformedProvenance = clone(provenance); + const malformedDispositionIndex = malformedProvenance.sourceReviewDispositions.findIndex( + ({ id }) => id === "codex-p2-windows-supervisor-registration", + ); + malformedProvenance.sourceReviewDispositions[malformedDispositionIndex].disposition = 7; + + assert.doesNotThrow(() => validateManifest(fixture.manifest, { + inventory: malformedInventory, + provenance: malformedProvenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + })); + const errors = validateManifest(fixture.manifest, { + inventory: malformedInventory, + provenance: malformedProvenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("evidence[0].scenarioIds"))); + assert.ok(errors.some((error) => error.includes("evidence[1].hostAlias"))); + assert.ok(errors.some((error) => error.includes("scenarioResults[0].evidenceIds"))); + assert.ok(errors.some((error) => error.includes("scenarioResults[1].hostAliases"))); + assert.ok(errors.some((error) => error.includes("scenarios[0].hosts[0]"))); + assert.ok(errors.some((error) => error.includes("dimensions.operatingSystems"))); + assert.ok(errors.some((error) => error.includes(`sourceReviewDispositions[${malformedDispositionIndex}].disposition`))); +}); + +test("manifest rejects unsafe evidence paths and obvious identifiers", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + completeManifest(fixture.manifest, fixture.evidenceRoot); + fixture.manifest.evidence[0].path = "../outside.txt"; + fixture.manifest.operatorNote = "Captured under C:\\Users\\ExamplePerson"; + const errors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "complete", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("safe relative path"))); + assert.ok(errors.some((error) => error.includes("Windows user profile path"))); +}); + +test("publication readiness requires role-based exact-SHA approval while gates stay disabled", (t) => { + const fixture = createFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + completeManifest(fixture.manifest, fixture.evidenceRoot); + fixture.manifest.approval = { + state: "approved", + approvedTargetSha: targetSha, + approverRole: "windows-release-maintainer", + approvedAt: "2026-08-01T14:00:00.000Z", + }; + const postDraftScenario = inventory.scenarios.find((scenario) => ( + scenario.acceptanceGateIds?.includes("draft-assets-and-website") + )); + const postDraftResult = fixture.manifest.scenarioResults.find((result) => ( + result.scenarioId === postDraftScenario.id + )); + const postDraftEvidenceIds = new Set(postDraftResult.evidenceIds); + postDraftResult.status = "pending"; + postDraftResult.hostAliases = []; + postDraftResult.evidenceIds = []; + fixture.manifest.evidence = fixture.manifest.evidence.filter((entry) => !postDraftEvidenceIds.has(entry.id)); + assert.deepEqual(validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "publication-readiness", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }), []); + + const draftErrors = validateManifest(fixture.manifest, { + inventory, + provenance, + expectedSha: targetSha, + phase: "draft-readiness", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(draftErrors.some((error) => error.includes(`${postDraftScenario.id}.status`))); + + const enabled = clone(fixture.manifest); + enabled.releaseGates.websiteReleaseReady = true; + const errors = validateManifest(enabled, { + inventory, + provenance, + expectedSha: targetSha, + phase: "publication-readiness", + artifactRoot: fixture.releaseDir, + evidenceRoot: fixture.evidenceRoot, + inventoryPath, + provenancePath, + }); + assert.ok(errors.some((error) => error.includes("websiteReleaseReady"))); +}); diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs new file mode 100644 index 000000000..d5aeb7151 --- /dev/null +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -0,0 +1,675 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; +import { + isGithubSafeAssetName, + resolveWindowsPackageIdentity, + windowsInstallerArtifactName, + windowsInstallerPattern, +} from "./windows-package-identity.mjs"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const desktopRoot = path.resolve(scriptDir, ".."); +const repoRoot = path.resolve(desktopRoot, "..", ".."); +const pkg = JSON.parse(fs.readFileSync(path.join(desktopRoot, "package.json"), "utf8")); +const releaseWorkflow = fs.readFileSync(path.join(repoRoot, ".github", "workflows", "release-core.yml"), "utf8").replace(/\r\n/g, "\n"); +const releaseTriggerWorkflow = fs.readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8").replace(/\r\n/g, "\n"); +const releasePublishWorkflow = fs.readFileSync(path.join(repoRoot, ".github", "workflows", "release-publish.yml"), "utf8").replace(/\r\n/g, "\n"); +const prepareWorkflow = fs.readFileSync(path.join(repoRoot, ".github", "workflows", "prepare-release.yml"), "utf8").replace(/\r\n/g, "\n"); +const ciWorkflow = fs.readFileSync(path.join(repoRoot, ".github", "workflows", "ci.yml"), "utf8").replace(/\r\n/g, "\n"); +const appUpdate = parseYaml(fs.readFileSync(path.join(desktopRoot, "resources", "app-update.yml"), "utf8")); +const downloadPage = fs.readFileSync(path.join(repoRoot, "apps", "web", "src", "app", "pages", "DownloadPage.tsx"), "utf8"); +const winArtifactValidator = fs.readFileSync( + path.join(desktopRoot, "scripts", "validate-win-artifacts.mjs"), + "utf8", +); +const electronBuilderWrapper = fs.readFileSync( + path.join(desktopRoot, "scripts", "run-electron-builder.mjs"), + "utf8", +); +const windowsTestBuild = fs.readFileSync( + path.join(desktopRoot, "scripts", "run-windows-test-build.mjs"), + "utf8", +); +const runtimeValidator = fs.readFileSync( + path.join(desktopRoot, "scripts", "validate-runtime-resources.mjs"), + "utf8", +); +const whisperValidator = fs.readFileSync( + path.join(desktopRoot, "scripts", "validate-whisper-resources.mjs"), + "utf8", +); +const afterPackScript = fs.readFileSync( + path.join(desktopRoot, "scripts", "after-pack-runtime-fixes.cjs"), + "utf8", +); +const windowsServiceManager = fs.readFileSync( + path.join(repoRoot, "apps", "ade-cli", "src", "serviceManager", "installWindows.ts"), + "utf8", +); +const windowsRuntimeSigner = fs.readFileSync( + path.join(repoRoot, "apps", "ade-cli", "scripts", "sign-windows-runtime.ps1"), + "utf8", +); +const desktopMain = fs.readFileSync(path.join(desktopRoot, "src", "main", "main.ts"), "utf8"); +const registerIpc = fs.readFileSync( + path.join(desktopRoot, "src", "main", "services", "ipc", "registerIpc.ts"), + "utf8", +); + +const remoteTargets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-x64"]; + +function jobBlock(workflow, jobName, nextJobName) { + const start = workflow.indexOf(`\n ${jobName}:\n`); + assert.notEqual(start, -1, `Expected active ${jobName} workflow job`); + const end = nextJobName ? workflow.indexOf(`\n ${nextJobName}:\n`, start + 1) : workflow.length; + assert.notEqual(end, -1, `Expected ${nextJobName} after ${jobName}`); + return workflow.slice(start, end); +} + +test("Windows package carries every remote runtime sidecar its validator requires", () => { + const runtimeResources = [...pkg.build.extraResources, ...pkg.build.win.extraResources] + .filter((entry) => entry.to === "runtime"); + const runtimeFilter = runtimeResources.flatMap((entry) => entry.filter); + for (const target of remoteTargets) { + const binary = target === "win32-x64" ? `ade-${target}.exe` : `ade-${target}`; + assert.ok(runtimeFilter.includes(binary), target); + assert.ok(runtimeFilter.includes(`ade-${target}.native.tar.gz`), `${target} native archive`); + } + const commonRuntimeFilter = pkg.build.extraResources.find((entry) => entry.to === "runtime").filter; + assert.equal(commonRuntimeFilter.some((entry) => entry.startsWith("ade-linux-")), false); +}); + +test("electron-builder owns packaged update metadata and preserves the upstream default", () => { + assert.equal(pkg.build.publish.owner, "arul28"); + assert.equal(pkg.build.publish.repo, "ADE"); + assert.deepEqual(appUpdate, { provider: "github", owner: "arul28", repo: "ADE" }); + assert.equal(pkg.build.extraResources.some((entry) => entry.to === "app-update.yml"), false); + assert.match(pkg.scripts["package:win"], /run-electron-builder\.mjs/); + assert.match( + electronBuilderWrapper, + /--config\.extraMetadata\.adeReleaseRepository=\$\{configuredRepository\}/, + ); + assert.match(desktopMain, /packageJson\.adeReleaseRepository/); + assert.ok( + (desktopMain.match(/releaseRepository: packagedReleaseRepository/g) ?? []).length >= 2, + "packaged repository must reach both updater state and release-link IPC", + ); + assert.match(registerIpc, /buildGithubReleaseUrl\(version, releaseRepository\)/); +}); + +test("local Windows test builds omit only cross-platform runtime sidecars", () => { + assert.match(pkg.scripts["dist:win:test"], /run-windows-test-build\.mjs/); + assert.match(windowsTestBuild, /ADE_RUNTIME_RESOURCES_ALLOW_HOST_ONLY: "1"/); + assert.match(windowsTestBuild, /ADE_WINDOWS_TEST_BUILD: "1"/); + assert.match(windowsTestBuild, /npm\.cmd.*"run", "dist:win"/s); + assert.match(runtimeValidator, /allTargets\.includes\(hostTarget\) \? \[hostTarget\] : \[\]/); + assert.match(winArtifactValidator, /Local test build: skipping macOS\/Linux remote runtime sidecars/); + assert.match(whisperValidator, /Local Windows test build: Whisper CLI is not bundled/); + assert.match(electronBuilderWrapper, /windowsHide: process\.platform === "win32"/); + assert.match(afterPackScript, /Pruned \$\{reason\} OpenCode install shim/); + assert.match(winArtifactValidator, /duplicate OpenCode Windows executable/); + assert.doesNotMatch(pkg.scripts["dist:win"], /ALLOW_HOST_ONLY/); + assert.doesNotMatch(pkg.scripts["dist:win:signed"], /ALLOW_HOST_ONLY/); + assert.doesNotMatch(pkg.scripts["dist:win"], /WINDOWS_TEST_BUILD/); + assert.doesNotMatch(pkg.scripts["dist:win:signed"], /WINDOWS_TEST_BUILD/); +}); + +test("Windows background service registration does not require administrator access", () => { + assert.match(windowsServiceManager, /HKCU\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run/); + assert.match(windowsServiceManager, /buildWindowsRunKeyAddArgs\(taskName, command\)/); + assert.match(windowsServiceManager, /buildWindowsStartLauncherArgs\(launcherPath\)/); + const installBlock = windowsServiceManager.slice( + windowsServiceManager.indexOf("export function installWindowsService"), + windowsServiceManager.indexOf("export function uninstallWindowsService"), + ); + assert.doesNotMatch(installBlock, /buildWindowsCreateTaskArgs\(/); +}); + +test("public Windows packaging fails closed on Authenticode signing", () => { + assert.match(pkg.scripts["dist:win:signed"], /package:win:signed/); + assert.match(pkg.scripts["dist:win:signed"], /validate:win:release:signed/); + assert.match(pkg.scripts["package:win:signed"], /--require-signing/); + + const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); + assert.match(windowsRelease, /ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \|\| inputs\.windows_proof/); + assert.match(windowsRelease, /npm run dist:win:signed/); + assert.match(windowsRelease, /ADE_RELEASE_REPOSITORY:\s*\$\{\{ github\.repository \}\}/); + // Azure Artifact Signing holds the private key and never releases the + // certificate, so the only signing material the job carries is a Microsoft + // Entra service principal. No PFX secret exists to reference any more. + assert.match(windowsRelease, /AZURE_TENANT_ID: \$\{\{ secrets\.AZURE_TENANT_ID \}\}/); + assert.match(windowsRelease, /AZURE_CLIENT_ID: \$\{\{ secrets\.AZURE_CLIENT_ID \}\}/); + assert.match(windowsRelease, /AZURE_CLIENT_SECRET: \$\{\{ secrets\.AZURE_CLIENT_SECRET \}\}/); + assert.doesNotMatch(releaseWorkflow, /WIN_CSC_LINK|WINDOWS_CSC_LINK|WINDOWS_CSC_KEY_PASSWORD/); + assert.match(windowsRelease, /WINDOWS_SIGNING_EXPECTED_SUBJECT/); + assert.doesNotMatch(windowsRelease, /ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT|ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT/); + assert.match(windowsRelease, /ADE_POSTHOG_PROJECT_TOKEN:\s*\$\{\{ secrets\.ADE_POSTHOG_PROJECT_TOKEN \}\}/); + assert.match(windowsRelease, /ADE_POSTHOG_HOST:\s*\$\{\{ secrets\.ADE_POSTHOG_HOST \}\}/); + assert.match( + fs.readFileSync(path.join(desktopRoot, "scripts", "validate-win-artifacts.mjs"), "utf8"), + /installerIdentity\.thumbprint !== appIdentity\.thumbprint/, + ); +}); + +test("signed packaging stops before electron-builder when credentials are absent", () => { + const wrapper = path.join(desktopRoot, "scripts", "run-electron-builder.mjs"); + const env = { ...process.env }; + delete env.AZURE_TENANT_ID; + delete env.AZURE_CLIENT_ID; + delete env.AZURE_CLIENT_SECRET; + const result = spawnSync(process.execPath, [wrapper, "--require-signing", "--win", "--x64"], { + cwd: desktopRoot, + env, + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match( + `${result.stdout}\n${result.stderr}`, + /Signed Windows packaging requires AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET/, + ); +}); + +test("signed packaging stops before electron-builder when the publisher is unpinned", () => { + const wrapper = path.join(desktopRoot, "scripts", "run-electron-builder.mjs"); + const env = { + ...process.env, + AZURE_TENANT_ID: "tenant", + AZURE_CLIENT_ID: "client", + AZURE_CLIENT_SECRET: "secret", + }; + delete env.WINDOWS_SIGNING_EXPECTED_SUBJECT; + const result = spawnSync(process.execPath, [wrapper, "--require-signing", "--win", "--x64"], { + cwd: desktopRoot, + env, + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match( + `${result.stdout}\n${result.stderr}`, + /Signed Windows packaging requires WINDOWS_SIGNING_EXPECTED_SUBJECT/, + ); +}); + +// Azure Artifact Signing renews the certificate daily and expires it after 72 +// hours, so thumbprint pinning would fail every release within days. Every +// layer that could accept the name must reject it instead of ignoring it. +test("thumbprint pinning is rejected everywhere rather than silently ignored", () => { + const wrapper = path.join(desktopRoot, "scripts", "run-electron-builder.mjs"); + const result = spawnSync(process.execPath, [wrapper, "--require-signing", "--win", "--x64"], { + cwd: desktopRoot, + env: { + ...process.env, + AZURE_TENANT_ID: "tenant", + AZURE_CLIENT_ID: "client", + AZURE_CLIENT_SECRET: "secret", + WINDOWS_SIGNING_EXPECTED_SUBJECT: "CN=Example", + WINDOWS_SIGNING_EXPECTED_THUMBPRINT: "0123456789ABCDEF", + }, + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match( + `${result.stdout}\n${result.stderr}`, + /WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported/, + ); + assert.match(winArtifactValidator, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported/); + assert.match(windowsRuntimeSigner, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported/); + assert.match(releaseWorkflow, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported/); + assert.match(prepareWorkflow, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT is not supported/); + // The subject is now the only pin, so the validator must require it outright + // rather than accepting either name. + assert.doesNotMatch( + winArtifactValidator, + /WINDOWS_SIGNING_EXPECTED_SUBJECT\s*"?\s*\+?\s*"?\s*or WINDOWS_SIGNING_EXPECTED_THUMBPRINT/, + ); + assert.doesNotMatch(winArtifactValidator, /expectedIdentity\.thumbprint &&/); +}); + +test("Windows packaging signs through Azure Artifact Signing and no local key material", () => { + // electron-builder 26 picks the Azure signing manager purely on the presence + // of win.azureSignOptions, above the single chokepoint that signs the + // packaged executable, its DLLs, the NSIS installer, and the uninstaller. + assert.match(electronBuilderWrapper, /--config\.win\.azureSignOptions\.publisherName=\$\{expectedSigningSubject\}/); + assert.match(electronBuilderWrapper, /--config\.win\.azureSignOptions\.endpoint=\$\{azureSigningEndpoint\}/); + assert.match( + electronBuilderWrapper, + /--config\.win\.azureSignOptions\.codeSigningAccountName=\$\{azureSigningAccountName\}/, + ); + assert.match( + electronBuilderWrapper, + /--config\.win\.azureSignOptions\.certificateProfileName=\$\{azureCertificateProfileName\}/, + ); + // The certificate lives for 72 hours, so an RFC3161 countersignature is what + // keeps a shipped installer verifiable afterwards. + assert.match( + electronBuilderWrapper, + /--config\.win\.azureSignOptions\.timestampRfc3161=http:\/\/timestamp\.acs\.microsoft\.com/, + ); + assert.match(electronBuilderWrapper, /--config\.forceCodeSigning=true/); + // The macOS Developer ID secrets must never become a Windows signing + // identity, and an unsigned dist:win must never reach the signing service. + assert.match(electronBuilderWrapper, /delete baseChildEnv\.CSC_LINK/); + assert.match(electronBuilderWrapper, /delete baseChildEnv\.CSC_KEY_PASSWORD/); + assert.match( + electronBuilderWrapper, + /AZURE_SIGNING_CREDENTIAL_ENV = \["AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET"\]/, + ); + assert.match(electronBuilderWrapper, /for \(const name of AZURE_SIGNING_CREDENTIAL_ENV\) \{\s*\n\s*delete baseChildEnv\[name\];/); + assert.doesNotMatch(electronBuilderWrapper, /CSC_LINK:|CSC_KEY_PASSWORD:|\.pfx|WINDOWS_CSC_/); +}); + +test("Windows packaging rejects unknown channels before electron-builder", () => { + const wrapper = path.join(desktopRoot, "scripts", "run-electron-builder.mjs"); + const result = spawnSync(process.execPath, [wrapper, "--win", "--x64"], { + cwd: desktopRoot, + env: { ...process.env, ADE_PACKAGE_CHANNEL: "betaa" }, + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}\n${result.stderr}`, /Unsupported ADE_PACKAGE_CHANNEL 'betaa'/); +}); + +test("Beta validation selects only Beta artifacts when Stable files are retained", () => { + const stable = resolveWindowsPackageIdentity("stable"); + const beta = resolveWindowsPackageIdentity("beta"); + const artifacts = [ + "ADE-1.2.3-win-x64.exe", + "ADE-Beta-1.2.3-win-x64.exe", + ]; + assert.equal(beta.executableName, "ADE Beta.exe"); + assert.deepEqual(artifacts.filter((name) => windowsInstallerPattern(beta).test(name)), [ + "ADE-Beta-1.2.3-win-x64.exe", + ]); + assert.deepEqual(artifacts.filter((name) => windowsInstallerPattern(stable).test(name)), [ + "ADE-1.2.3-win-x64.exe", + ]); + assert.match(winArtifactValidator, /windowsInstallerPattern\(packageIdentity\)/); +}); + +test("Windows installer names stay GitHub-safe so latest.yml matches the published asset", () => { + const stable = resolveWindowsPackageIdentity("stable"); + const beta = resolveWindowsPackageIdentity("beta"); + const alpha = resolveWindowsPackageIdentity("alpha"); + // electron-builder writes the installer from ${productName} but rewrites + // latest.yml's url/path to a space-free "safe" name for GitHub, and + // release-publish.yml publishes the on-disk file through `gh release upload`, + // where GitHub normalizes disallowed characters again. Any space in the + // artifact name therefore points the updater feed at a file nobody published. + for (const identity of [stable, beta, alpha]) { + const rendered = windowsInstallerArtifactName(identity) + .replace("${version}", "1.2.3") + .replace("${arch}", "x64") + .replace("${ext}", "exe"); + assert.ok( + isGithubSafeAssetName(rendered), + `${identity.packageChannel} installer name must be GitHub-safe, got ${rendered}`, + ); + assert.ok(windowsInstallerPattern(identity).test(rendered)); + } + assert.equal(stable.artifactBaseName, "ADE"); + assert.equal(beta.artifactBaseName, "ADE-Beta"); + assert.equal(pkg.build.win.artifactName, windowsInstallerArtifactName(stable)); + assert.doesNotMatch(pkg.build.win.artifactName, /\$\{productName\}/); + assert.match( + electronBuilderWrapper, + /--config\.win\.artifactName=\$\{windowsInstallerArtifactName\(channelIdentity\)\}/, + ); + assert.match(winArtifactValidator, /isGithubSafeAssetName\(installerName\)/); + assert.match(winArtifactValidator, /build\.win\.artifactName must be/); + // The Stable glob must not depend on step ordering to avoid selecting the + // Beta installer once both live in apps/desktop/release. + const packageJob = jobBlock(ciWorkflow, "package-win", "validate-docs"); + assert.match(packageJob, /release\/ADE-\[0-9\]\*-win-x64\.exe/); + assert.match(packageJob, /release\/ADE-Beta-\[0-9\]\*-win-x64\.exe/); + assert.doesNotMatch(packageJob, /ADE Beta-/); + assert.match(releaseWorkflow, /release\/ADE-\[0-9\]\*-win-x64\.exe/); +}); + +test("standalone Windows runtime signing uses only canonical credentials and validates publisher identity", () => { + const runtimeBuild = jobBlock(releaseWorkflow, "build-runtime-binaries", "build-results"); + const windowsSignStep = runtimeBuild.slice( + runtimeBuild.indexOf("- name: Sign and validate standalone Windows runtime"), + runtimeBuild.indexOf("- name: Materialize runtime notarization API key"), + ); + assert.match(runtimeBuild, /target: win32-x64[\s\S]*os: windows-latest[\s\S]*binary: ade-win32-x64\.exe/); + assert.match(windowsSignStep, /matrix\.target == 'win32-x64'/); + assert.match(windowsSignStep, /ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \|\| inputs\.windows_proof/); + assert.match(windowsSignStep, /AZURE_TENANT_ID: \$\{\{ secrets\.AZURE_TENANT_ID \}\}/); + assert.match(windowsSignStep, /AZURE_CLIENT_ID: \$\{\{ secrets\.AZURE_CLIENT_ID \}\}/); + assert.match(windowsSignStep, /AZURE_CLIENT_SECRET: \$\{\{ secrets\.AZURE_CLIENT_SECRET \}\}/); + assert.match(windowsSignStep, /WINDOWS_SIGNING_EXPECTED_SUBJECT/); + assert.doesNotMatch(windowsSignStep, /(?:^|\s)(?:WIN_CSC_LINK|WIN_CSC_KEY_PASSWORD|CSC_LINK|CSC_KEY_PASSWORD|WINDOWS_CSC_LINK|WINDOWS_CSC_KEY_PASSWORD):/m); + assert.match(windowsSignStep, /sign-windows-runtime\.ps1/); + // The standalone runtime signs through the same Azure Artifact Signing + // mechanism electron-builder 26 uses, so there is one signing code path for + // every Windows artifact ADE publishes. + assert.match(windowsRuntimeSigner, /Invoke-TrustedSigning/); + assert.match(windowsRuntimeSigner, /-CodeSigningAccountName \$signingAccountName/); + assert.match(windowsRuntimeSigner, /-CertificateProfileName \$certificateProfileName/); + assert.match(windowsRuntimeSigner, /timestamp\.acs\.microsoft\.com/); + // Post-sign verification stays exactly as strict: valid status, a trusted + // RFC3161 timestamp, and the pinned publisher subject. + assert.match(windowsRuntimeSigner, /Get-AuthenticodeSignature/); + assert.match(windowsRuntimeSigner, /SignatureStatus\]::Valid/); + assert.match(windowsRuntimeSigner, /TimeStamperCertificate/); + assert.match(windowsRuntimeSigner, /WINDOWS_SIGNING_EXPECTED_SUBJECT/); + // The service never releases the certificate, so the signer must not contain + // any local key-material handling at all. + assert.doesNotMatch(windowsRuntimeSigner, /X509Certificate2|\.pfx|WINDOWS_CSC_|Set-AuthenticodeSignature/); + assert.doesNotMatch(windowsRuntimeSigner, /Write-Output.*(?:AZURE_CLIENT_SECRET|expectedSubject)/); +}); + +test("standalone Windows release assets remain behind the publication gate", () => { + const publish = jobBlock(releasePublishWorkflow, "publish-release", null); + const runtimeBuild = jobBlock(releaseWorkflow, "build-runtime-binaries", "build-results"); + const installer = fs.readFileSync( + path.join(repoRoot, "apps", "ade-cli", "scripts", "install-runtime.ps1"), + "utf8", + ); + const releaseFiles = publish.slice(publish.indexOf("base_files=("), publish.indexOf("if [ \"$PUBLISH_WINDOWS\"", publish.indexOf("base_files=("))); + assert.doesNotMatch(releaseFiles, /install\.ps1|ade-win32-x64/); + assert.match(publish, /installers=\(release-assets\/win\/ADE-\*-win-x64\.exe\)/); + assert.match(publish, /test -s release-assets\/runtime\/install\.ps1/); + assert.match(publish, /test -s release-assets\/runtime\/ade-win32-x64\.exe/); + assert.match(publish, /test -s release-assets\/runtime\/ade-win32-x64\.native\.tar\.gz/); + // The Windows standalone manifest is written by Git Bash on windows-latest, + // where sha256sum marks binary reads with a leading '*'. It is normalized + // before it is parsed or verified. + assert.match(publish, /sed 's\/\^\\\(\[0-9a-f\]\\\{64\\\}\\\) \[ \*\]\/\\1 \/'/); + assert.match(publish, /\(cd release-assets\/runtime && sha256sum -c "\$windows_sums"\)/); + // Windows standalone assets are named only inside the publication-gated + // branch, and every published asset is sourced from this run. + assert.match(publish, /if \[ "\$PUBLISH_WINDOWS" = "1" \]; then[\s\S]*release-assets\/runtime\/install\.ps1[\s\S]*release-assets\/runtime\/ade-win32-x64\.exe/); + assert.doesNotMatch(publish, /release-assets\/win\/ade-|release-assets\/win\/install\.|release-assets\/win\/SHA256SUMS/); + assert.match(publish, /for asset in "\$\{existing_assets\[@\]\}"; do[\s\S]*gh release delete-asset/); + assert.match(publish, /gh release delete-asset "\$TAG_NAME" "\$asset" --repo "\$GH_REPO" --yes/); + assert.match(publish, /Draft release asset inventory differs from the exact validated set/); + assert.match(runtimeBuild, /name: Assemble signed Windows standalone runtime bundle/); + assert.match(runtimeBuild, /matrix\.target == 'win32-x64' && \(vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \|\| inputs\.windows_proof\)/); + assert.match(runtimeBuild, /sha256sum install\.ps1 ade-win32-x64\.exe ade-win32-x64\.native\.tar\.gz/); + assert.match(runtimeBuild, /apps\/ade-cli\/dist-static\/install\.ps1/); + assert.match(runtimeBuild, /apps\/ade-cli\/dist-static\/SHA256SUMS/); + assert.match(installer, /Verify-Checksum/); + assert.match(installer, /ADE_RELEASE_ASSET_DIR/); + assert.match(installer, /Copy-Item -LiteralPath \$source -Destination \$Destination -Force/); + assert.match(installer, /ade-win32-x64\.exe|\$binaryAsset/); + assert.match(installer, /serve --install-service/); + assert.match(installer, /serve --service-status --json/); + assert.match(installer, /if \(\$serviceStatus\.installed\)/); + assert.match(installer, /Install-UserPath/); + assert.match(installer, /Recovery files were retained at \$tempRoot/); + assert.match(installer, /if \(-not \$preserveTempForRecovery\)/); +}); + +test("Windows release assets are validated and published as one release set", () => { + const publish = jobBlock(releasePublishWorkflow, "publish-release", null); + const verify = jobBlock(releaseWorkflow, "verify", "build-mac-release"); + const workflowHeader = releaseWorkflow.slice(0, releaseWorkflow.indexOf("\njobs:\n")); + assert.match(workflowHeader, /contents: read/); + assert.doesNotMatch(workflowHeader, /contents: write/); + assert.match(releaseTriggerWorkflow, /permissions:\s*\n\s+actions: read\s*\n\s+checks: read\s*\n\s+contents: write/); + // GitHub validates a called workflow's job permissions statically, before any + // job-level `if:` runs, so release-core.yml must not contain a write-capable + // job at all or the read-only prepare-release.yml caller fails to parse. + // Publishing therefore lives in its own reusable workflow. + assert.doesNotMatch(releaseWorkflow, /contents: write/); + assert.equal(releaseWorkflow.includes("\n publish-release:\n"), false); + assert.match(publish, /permissions:\s*\n\s+actions: read\s*\n\s+contents: write/); + assert.match(publish, /name: ade-win-release-/); + // Only the tag-triggered release workflow may call the publishing workflow. + assert.match(releaseTriggerWorkflow, /uses: \.\/\.github\/workflows\/release-publish\.yml/); + assert.doesNotMatch(prepareWorkflow, /release-publish\.yml/); + // Windows is a first-class platform: with its gate on, a failed or skipped + // Windows build blocks the draft exactly as a failed macOS build does, and + // always() still lets the gate evaluate when Windows is legitimately skipped. + assert.match(releaseTriggerWorkflow, /always\(\)\s*\n\s+&& needs\.run-release\.outputs\.runtime_result == 'success'/); + assert.match(releaseTriggerWorkflow, /needs\.run-release\.outputs\.mac_result == 'success'/); + assert.match( + releaseTriggerWorkflow, + /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'\s*\n\s+\|\| needs\.run-release\.outputs\.windows_result == 'success'/, + ); + // The gate reads release-core.yml's outputs, so they must exist and be fed by + // an always() job that reports every build result. + for (const output of ["runtime_result", "mac_result", "windows_result"]) { + assert.match(releaseWorkflow, new RegExp(`value: \\$\\{\\{ jobs\\.build-results\\.outputs\\.${output} \\}\\}`)); + } + const buildResults = jobBlock(releaseWorkflow, "build-results", null); + assert.match(buildResults, /if: always\(\)/); + assert.match(buildResults, /runtime_result: \$\{\{ needs\.build-runtime-binaries\.result \}\}/); + assert.match(buildResults, /mac_result: \$\{\{ needs\.build-mac-release\.result \}\}/); + assert.match(buildResults, /windows_result: \$\{\{ needs\.build-win-release\.result \}\}/); + // verify fails fast on missing signing material instead of on stale proof + // bindings, which no longer exist. + assert.match(verify, /Windows releases require the AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET secrets/); + assert.match(verify, /Windows releases require the WINDOWS_SIGNING_EXPECTED_SUBJECT secret to pin the approved publisher/); + assert.match(verify, /PUBLISH_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \}\}/); + assert.match(publish, /if: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \}\}/); + assert.match(publish, /PUBLISH_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \}\}/); + assert.match(publish, /if \[ "\$PUBLISH_WINDOWS" = "1" \]; then/); + assert.match(publish, /release-assets\/win\/ADE-\*-win-x64\.exe/); + assert.match(publish, /release-assets\/win\/ADE-\*-win-x64\.exe\.blockmap/); + assert.match(publish, /release-assets\/win\/latest\.yml/); + assert.match(publish, /--json isDraft/); + assert.match(publish, /Refusing to overwrite published assets/); + assert.match(publish, /if \[ "\$is_draft" != "true" \]; then/); +}); + +test("Windows NSIS install and uninstall own only their per-user channel integration", () => { + assert.equal(pkg.build.nsis.include, "build/installer.nsh"); + assert.equal(pkg.build.nsis.oneClick, false); + assert.equal(pkg.build.nsis.perMachine, false); + assert.equal(pkg.build.nsis.allowElevation, false); + assert.equal(pkg.build.nsis.runAfterFinish, false); + assert.equal(pkg.build.nsis.deleteAppDataOnUninstall, false); + assert.ok( + pkg.build.win.extraResources.some((entry) => entry.to === "ade-cli/windows-install-setup.ps1"), + "Windows package must carry the install setup script", + ); + assert.ok( + pkg.build.win.extraResources.some((entry) => entry.to === "ade-cli/windows-uninstall-cleanup.ps1"), + "Windows package must carry the uninstall cleanup script", + ); + const nsis = fs.readFileSync(path.join(desktopRoot, "build", "installer.nsh"), "utf8"); + const cleanup = fs.readFileSync( + path.join(desktopRoot, "scripts", "windows-uninstall-cleanup.ps1"), + "utf8", + ); + const setup = fs.readFileSync( + path.join(desktopRoot, "scripts", "windows-install-setup.ps1"), + "utf8", + ); + assert.match(nsis, /!macro customInstall/); + assert.match(nsis, /windows-install-setup\.ps1/); + assert.match(nsis, /!macro customUnInstall/); + assert.match(nsis, /windows-uninstall-cleanup\.ps1/); + assert.match(nsis, /-AppExecutableName "\$\{APP_EXECUTABLE_FILENAME\}"/); + assert.match(nsis, /-PackageChannel "\$2"/); + assert.match(nsis, /Abort/); + assert.match(cleanup, /"serve", "--uninstall-service"/); + assert.match(cleanup, /Start-Process/); + assert.match(cleanup, /-Wait/); + assert.match(cleanup, /cleanupProcess\.ExitCode/); + assert.match(cleanup, /ADE_PACKAGE_CHANNEL = \$normalizedPackageChannel/); + assert.match(cleanup, /\$env:ADE_HOME = \$channelAdeHome/); + assert.match(cleanup, /app\.asar\.unpacked\\node_modules/); + assert.match(cleanup, /NODE_PATH = \$nodePathEntries/); + assert.match(cleanup, /SetEnvironmentVariable\("Path"/); + assert.match(cleanup, /Remove-OwnedStableProtocolRegistration/); + assert.match(setup, /install-path\.cmd/); + assert.match(setup, /serve --install-service/); + assert.match(setup, /ade-\$PackageChannel\.cmd/); + assert.match(electronBuilderWrapper, /resolveWindowsPackageIdentity/); + assert.match(electronBuilderWrapper, /--config\.fileAssociations\.name=\$\{channelIdentity\.fileClass\}/); + assert.doesNotMatch(electronBuilderWrapper, /--config\.win\.fileAssociations/); +}); + +test("release preflight validates the exact approved commit", () => { + const verify = jobBlock(releaseWorkflow, "verify", "build-mac-release"); + assert.match(prepareWorkflow, /target_sha:\s*\n\s+description: Exact 40-character commit SHA/); + assert.match(prepareWorkflow, /ref: \$\{\{ inputs\.target_sha \}\}/); + assert.match(prepareWorkflow, /target_sha must be the exact 40-character commit SHA approved for release/); + assert.match(prepareWorkflow, /target_ref: \$\{\{ needs\.resolve\.outputs\.target_sha \}\}/); + assert.doesNotMatch(prepareWorkflow, /ref: main/); + assert.match(verify, /name: Validate release tag and target binding/); + assert.match(verify, /\^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+/); + assert.match(verify, /git ls-remote --exit-code --tags origin "refs\/tags\/\$RELEASE_TAG"/); + assert.match(verify, /git rev-list -n 1 "refs\/tags\/\$RELEASE_TAG"/); + assert.match(verify, /Release tag \$RELEASE_TAG resolves to \$tag_sha, not approved target \$target_sha/); +}); + +test("Windows proof collection is opt-in, non-publishing, and emits an exact-SHA manifest", () => { + const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); + assert.match(prepareWorkflow, /name: Prepare signed Windows proof/); + assert.match(prepareWorkflow, /Signed Windows proof requires the AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET secrets/); + assert.match(prepareWorkflow, /windows_proof: \$\{\{ inputs\.windows_proof \}\}/); + // There is no publish input any more. The dry run cannot publish because it + // holds a read-only token and never calls the publishing workflow, not + // because it passes a flag the shared workflow is trusted to honour. + assert.doesNotMatch(prepareWorkflow, /publish:/); + assert.match(prepareWorkflow, /permissions:\s*\n\s+actions: read\s*\n\s+checks: read\s*\n\s+contents: read/); + assert.doesNotMatch(prepareWorkflow, /contents: write/); + // Proof collection never depends on a repository variable state, so it can + // run before Windows is enabled and again as a regression check after. + assert.doesNotMatch(prepareWorkflow, /vars\.ADE_WINDOWS_/); + // The proof bundle is built only under the explicit input; ordinary releases + // build, sign and validate Windows without it. + for (const proofStep of [ + "Stage standalone runtime proof assets", + "Generate exact-SHA Windows proof manifest", + "Validate exact-SHA Windows build proof", + "Upload validated Windows proof bundle", + ]) { + const stepIndex = windowsRelease.indexOf(`- name: ${proofStep}`); + assert.notEqual(stepIndex, -1, `expected proof step ${proofStep}`); + assert.match( + windowsRelease.slice(stepIndex, stepIndex + 400), + /if: \$\{\{ inputs\.windows_proof \}\}/, + `${proofStep} must be gated on the windows_proof input`, + ); + } + assert.match(windowsRelease, /name: ade-win-proof-\$\{\{ inputs\.release_tag \}\}/); + assert.match(windowsRelease, /windows-proof-manifest\.mjs create/); + assert.match(windowsRelease, /--target-sha "\$\{\{ inputs\.target_ref \}\}"/); + assert.match(windowsRelease, /windows-proof-manifest\.mjs validate/); + assert.match(windowsRelease, /--phase build/); + assert.match(windowsRelease, /--expected-sha "\$\{\{ inputs\.target_ref \}\}"/); + assert.match(windowsRelease, /--expected-tag "\$\{\{ inputs\.release_tag \}\}"/); + assert.match(windowsRelease, /--expected-run-id "\$\{\{ github\.run_id \}\}"/); + assert.match(windowsRelease, /name: Stage standalone runtime proof assets/); + assert.match(windowsRelease, /ade-win32-x64\.exe/); + assert.match(windowsRelease, /ade-win32-x64\.native\.tar\.gz/); + assert.match(windowsRelease, /install-runtime\.ps1/); + assert.match(windowsRelease, /SHA256SUMS/); + assert.match(windowsRelease, /apps\/desktop\/release\/windows-proof-manifest\.json/); +}); + +test("Windows builds fresh on the release tag with no approved-proof promotion", () => { + const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); + // The desktop Windows build is no longer confined to non-publishing runs, so + // a v* tag builds, signs, validates and publishes Windows in-run. + assert.doesNotMatch(windowsRelease, /inputs\.publish == false/); + assert.match(windowsRelease, /runs-on: windows-latest/); + assert.match(windowsRelease, /- verify\s*\n\s+- build-runtime-binaries/); + assert.match(windowsRelease, /name: Upload validated Windows release artifacts/); + assert.match(windowsRelease, /name: ade-win-release-\$\{\{ inputs\.release_tag \}\}/); + // The published artifact carries the installer set only; standalone runtime + // files are published from build-runtime-binaries instead. + assert.doesNotMatch( + windowsRelease.slice( + windowsRelease.indexOf("- name: Upload validated Windows release artifacts"), + windowsRelease.indexOf("- name: Stage standalone runtime proof assets"), + ), + /ade-darwin-|ade-linux-|install\.sh/, + ); + // Promotion of a previously approved proof run is gone, along with every + // repository variable that only existed to bind it. + assert.equal(releaseWorkflow.includes("promote-approved-win-proof"), false); + assert.doesNotMatch( + releaseWorkflow, + /ADE_WINDOWS_APPROVED_PROOF_SHA|ADE_WINDOWS_APPROVED_PROOF_RUN_ID|ADE_WINDOWS_APPROVED_BUILD_MANIFEST_SHA256|ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED|ADE_WINDOWS_SIGNED_BUILD_ENABLED/, + ); +}); + +test("Windows proof and draft assembly enforce exact runtime and remote asset inventories", () => { + const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); + const publish = jobBlock(releasePublishWorkflow, "publish-release", null); + assert.match(windowsRelease, /\$unexpectedRuntimeFiles = @\(\$actualRuntimeFiles \| Where-Object \{ \$_ -notin \$runtimeFiles \}\)/); + assert.match(windowsRelease, /Runtime artifact inventory mismatch/); + assert.doesNotMatch(windowsRelease, /Get-ChildItem[^\n]+-Filter "ade-\*"[^\n]+ForEach-Object \{/); + // The cross-platform runtime allowlist now guards the ungated publish path, + // so it holds in every flag state rather than only when Windows publishes. + assert.match(publish, /Runtime artifacts contain an unauthorized or missing entry/); + assert.match(publish, /Windows standalone checksum manifest does not name the exact authorized Windows runtime set/); + assert.match(publish, /latest\.yml does not reference the published installer/); + assert.match(publish, /gh api "repos\/\$GH_REPO\/commits\/\$TAG_NAME" --jq '\.sha'/); + assert.match(publish, /Existing draft tag \$TAG_NAME resolves to \$release_tag_target, not approved target \$approved_target/); + assert.match(publish, /Draft release tag \$TAG_NAME resolves to \$final_tag_target, not approved target \$approved_target/); + assert.match(publish, /for asset in "\$\{existing_assets\[@\]\}"; do[\s\S]*gh release delete-asset/); + assert.doesNotMatch(publish, /case "\$asset" in/); + assert.match(publish, /Draft release asset inventory differs from the exact validated set/); + assert.match(publish, /gh release view "\$TAG_NAME" --repo "\$GH_REPO" --json assets --jq '\.assets\[\]\.name'/); +}); + +test("one repository variable decides whether a release carries Windows", () => { + const windowsRelease = jobBlock(releaseWorkflow, "build-win-release", "build-runtime-binaries"); + const publish = jobBlock(releasePublishWorkflow, "publish-release", null); + // Exactly one maintainer-facing switch, matching the macOS bar: secrets are + // provisioned once, the gate is flipped once, then tags just work. + assert.match(windowsRelease, /if: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' \|\| inputs\.windows_proof \}\}/); + assert.match(releaseTriggerWorkflow, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'/); + assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1'/); + const windowsVariables = new Set( + ([releaseWorkflow, releaseTriggerWorkflow, releasePublishWorkflow] + .join("\n") + .match(/vars\.ADE_WINDOWS_[A-Z0-9_]+/g) ?? []).map((entry) => entry.slice("vars.".length)), + ); + assert.deepEqual([...windowsVariables], ["ADE_WINDOWS_PUBLIC_RELEASE_ENABLED"]); +}); + +test("pull requests build and smoke an unsigned Windows installer", () => { + const packageJob = jobBlock(ciWorkflow, "package-win", "validate-docs"); + assert.match(packageJob, /runs-on: windows-latest/); + assert.match(packageJob, /npm run dist:win/); + assert.match(packageJob, /ADE_PACKAGE_CHANNEL: beta/); + assert.match(packageJob, /windows-installed-product-smoke\.ps1/); + assert.match(packageJob, /-CompanionInstallerPath/); + assert.match(packageJob, /ADE_STABLE_INSTALLER/); + const installedSmoke = fs.readFileSync( + path.join(desktopRoot, "scripts", "windows-installed-product-smoke.ps1"), + "utf8", + ); + assert.match(installedSmoke, /Stop-InstalledProductProcesses/); + assert.match(installedSmoke, /ExecutablePath/); + assert.match(installedSmoke, /missing-executable repair/); + assert.match(packageJob, /Test Stable and Beta installed-product lifecycles/); + assert.doesNotMatch(packageJob, /dist:win:signed/); + const ciPass = jobBlock(ciWorkflow, "ci-pass", null); + assert.match(ciPass, /- package-win/); +}); + +test("Windows package smoke requires every bundled provider runtime", () => { + assert.ok( + pkg.build.asarUnpack.includes("node_modules/@cursor/sdk-win32-x64/**"), + "Cursor's Windows native helpers must be unpacked so Electron can execute them", + ); + assert.match(winArtifactValidator, /Claude executable source.*bundled/i); + assert.doesNotMatch(winArtifactValidator, /Claude CLI is not installed.*skipping live Claude startup/i); + assert.match(winArtifactValidator, /Codex executable source.*bundled/i); + assert.match(winArtifactValidator, /OpenCode.*--version/i); + assert.match(winArtifactValidator, /cursorSdkCreateAgentPlatform/); + assert.match(winArtifactValidator, /cursorNativeRgPath/); + assert.match(winArtifactValidator, /droidSdkCreateSession/); +}); + +test("download page gates the Windows release and enables dedicated analytics", () => { + assert.match(downloadPage, /VITE_ADE_WINDOWS_DOWNLOAD_ENABLED/); + assert.match(downloadPage, /=== "1"/); + assert.match(downloadPage, /signed Windows release is approved/); + assert.match(downloadPage, /MARKETING_FEATURES\.DOWNLOAD_WINDOWS/); + assert.match(downloadPage, /WINDOWS_DOWNLOAD_ENABLED \? LINKS\.releasesLatest : LINKS\.releases/); +}); diff --git a/apps/desktop/scripts/windows-uninstall-cleanup.ps1 b/apps/desktop/scripts/windows-uninstall-cleanup.ps1 new file mode 100644 index 000000000..9920afa6b --- /dev/null +++ b/apps/desktop/scripts/windows-uninstall-cleanup.ps1 @@ -0,0 +1,371 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$InstallDir, + [string]$AppExecutableName = "", + [string]$PackageChannel = "stable", + [string]$AdeHome = "", + [string]$CliBinDir = "", + [switch]$SkipServiceRemoval, + [switch]$SkipUserPathUpdate, + [switch]$SkipProtocolRemoval +) + +$ErrorActionPreference = "Stop" + +function Remove-TrailingDirectorySeparators([string]$Value) { + $root = [System.IO.Path]::GetPathRoot($Value) + $minimumLength = if ($null -eq $root) { 0 } else { $root.Length } + while ( + $Value.Length -gt $minimumLength -and + ($Value.EndsWith("\", [System.StringComparison]::Ordinal) -or + $Value.EndsWith("/", [System.StringComparison]::Ordinal)) + ) { + $Value = $Value.Substring(0, $Value.Length - 1) + } + return $Value +} + +function Resolve-NormalizedPath([string]$Value) { + $fullPath = [System.IO.Path]::GetFullPath($Value) + if (-not ("Ade.Windows.PathNormalization" -as [type])) { + Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +using System.Text; +namespace Ade.Windows { + public static class PathNormalization { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern uint GetLongPathName( + string shortPath, StringBuilder longPath, uint bufferLength); + } +} +"@ | Out-Null + } + + # GetFullPath resolves relative segments but does not consistently expand + # DOS 8.3 names under Windows PowerShell. GetLongPathName makes an existing + # path compare identically whether NSIS, Node, or the user supplied its short + # or long spelling. Nonexistent paths retain their normalized full spelling. + $buffer = New-Object System.Text.StringBuilder 32768 + $length = [Ade.Windows.PathNormalization]::GetLongPathName( + $fullPath, + $buffer, + [uint32]$buffer.Capacity + ) + if ($length -gt 0 -and $length -lt $buffer.Capacity) { + $fullPath = $buffer.ToString() + } + return Remove-TrailingDirectorySeparators $fullPath +} + +function Test-CliShimOwnedByInstall( + [string]$ShimPath, + [string]$ExpectedCliDir, + [string[]]$ExpectedWrapperNames +) { + $contents = Get-Content -LiteralPath $ShimPath -Raw -ErrorAction Stop + foreach ($line in ($contents -split "`r?`n")) { + $match = [System.Text.RegularExpressions.Regex]::Match( + $line, + '^\s*"(?[^"]+\.cmd)"\s+%\*\s*$', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase + ) + if (-not $match.Success) { continue } + + $targetPath = $match.Groups["target"].Value + $targetName = [System.IO.Path]::GetFileName($targetPath) + if (-not ($ExpectedWrapperNames | Where-Object { + [string]::Equals($_, $targetName, [System.StringComparison]::OrdinalIgnoreCase) + })) { continue } + + try { + $targetDir = Resolve-NormalizedPath ([System.IO.Path]::GetDirectoryName($targetPath)) + if ([string]::Equals( + $targetDir, + $ExpectedCliDir, + [System.StringComparison]::OrdinalIgnoreCase + )) { + return $true + } + } catch { + # An invalid command target is not owned by this installation. + } + } + return $false +} + +function Restore-EnvironmentValue([string]$Name, [string]$Value, [bool]$WasPresent) { + if ($WasPresent) { + [System.Environment]::SetEnvironmentVariable($Name, $Value, "Process") + } else { + [System.Environment]::SetEnvironmentVariable($Name, $null, "Process") + } +} + +function Send-EnvironmentChanged { + try { + if (-not ("Ade.Windows.EnvironmentBroadcast" -as [type])) { + Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +namespace Ade.Windows { + public static class EnvironmentBroadcast { + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + public static extern IntPtr SendMessageTimeout( + IntPtr hWnd, uint message, UIntPtr wParam, string lParam, + uint flags, uint timeout, out UIntPtr result); + } +} +"@ + } + $result = [UIntPtr]::Zero + [void][Ade.Windows.EnvironmentBroadcast]::SendMessageTimeout( + [IntPtr]0xffff, 0x1a, [UIntPtr]::Zero, "Environment", 2, 5000, [ref]$result) + } catch { + Write-Warning "The user PATH was cleaned, but running shells may need to be restarted." + } +} + +function Remove-OwnedStableProtocolRegistration([string]$ExpectedExecutablePath) { + $protocolRoot = "Registry::HKEY_CURRENT_USER\Software\Classes\ade" + $commandKey = Join-Path $protocolRoot "shell\open\command" + if (-not (Test-Path -LiteralPath $commandKey)) { return } + try { + $command = [string](Get-Item -LiteralPath $commandKey -ErrorAction Stop).GetValue("") + if ([string]::IsNullOrWhiteSpace($command)) { return } + $normalizedExpected = Resolve-NormalizedPath $ExpectedExecutablePath + $quotedExecutable = [Text.RegularExpressions.Regex]::Match($command, '^\s*"(?[^"]+\.exe)"') + if (-not $quotedExecutable.Success) { return } + $registeredExecutable = Resolve-NormalizedPath $quotedExecutable.Groups["exe"].Value + if ([string]::Equals( + $registeredExecutable, + $normalizedExpected, + [StringComparison]::OrdinalIgnoreCase + )) { + Remove-Item -LiteralPath $protocolRoot -Recurse -Force -ErrorAction Stop + } + } catch { + throw "ADE could not remove its owned ade:// protocol registration: $($_.Exception.Message)" + } +} + +function Restore-FileAssociationDefaults([string]$Channel) { + $ownedClass = if ($Channel -eq "stable") { "com.ade.desktop.files" } else { "com.ade.desktop.$Channel.files" } + $fallbackClasses = @( + "com.ade.desktop.files", + "com.ade.desktop.beta.files", + "com.ade.desktop.alpha.files" + ) | Where-Object { + $_ -ne $ownedClass -and (Test-Path -LiteralPath "Registry::HKEY_CURRENT_USER\Software\Classes\$_") + } + $fallbackClass = @($fallbackClasses)[0] + $classesRoot = "Registry::HKEY_CURRENT_USER\Software\Classes" + if (-not (Test-Path -LiteralPath $classesRoot)) { return } + foreach ($extensionKey in Get-ChildItem -LiteralPath $classesRoot -ErrorAction Stop | Where-Object { $_.PSChildName.StartsWith(".") }) { + $currentDefault = [string]$extensionKey.GetValue("") + if (-not [string]::Equals($currentDefault, $ownedClass, [StringComparison]::OrdinalIgnoreCase)) { continue } + Set-Item -LiteralPath $extensionKey.PSPath -Value $(if ($fallbackClass) { $fallbackClass } else { "" }) -ErrorAction Stop + } +} + +function Get-ShortSha256([string]$Value) { + $sha = [Security.Cryptography.SHA256]::Create() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace("-", "").Substring(0, 12).ToLowerInvariant() + } finally { + $sha.Dispose() + } +} + +function Remove-ChannelStartupWithoutPackagedCli( + [string]$AdeHome, + [string]$Channel +) { + $serviceName = if ($Channel -eq "stable") { "com.ade.runtime" } else { "com.ade.runtime.$Channel" } + $baseUserName = $env:USERNAME + if ([string]::IsNullOrWhiteSpace($baseUserName)) { + throw "ADE could not resolve the current Windows user for startup cleanup." + } + $userName = if ([string]::IsNullOrWhiteSpace($env:USERDOMAIN) -or $baseUserName.Contains("\")) { + $baseUserName + } else { + "$($env:USERDOMAIN)\$baseUserName" + } + $identity = "$($serviceName.ToLowerInvariant())`0$($userName.ToLowerInvariant())" + $taskName = "ADE Runtime ($Channel-$(Get-ShortSha256 $identity))" + $launcherPath = Join-Path $AdeHome "runtime\brain-service-$(Get-ShortSha256 $serviceName).ps1" + $pidPath = "$launcherPath.pid.json" + + if (Test-Path -LiteralPath $pidPath -PathType Leaf) { + try { + $record = Get-Content -LiteralPath $pidPath -Raw | ConvertFrom-Json + $supervisorPid = [int]$record.supervisorPid + if ($supervisorPid -gt 0) { + $process = Get-CimInstance Win32_Process -Filter "ProcessId = $supervisorPid" -ErrorAction SilentlyContinue + if ($process -and ([string]$process.CommandLine).IndexOf($launcherPath, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + & taskkill.exe /PID $supervisorPid /T /F | Out-Null + } + } + } catch { + Write-Warning "ADE could not verify its stale startup PID record: $($_.Exception.Message)" + } + } + + $runKey = "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" + if (Test-Path -LiteralPath $runKey) { + $key = Get-Item -LiteralPath $runKey -ErrorAction Stop + $command = [string]$key.GetValue($taskName, "") + if (-not [string]::IsNullOrWhiteSpace($command)) { + if ($command.IndexOf($launcherPath, [StringComparison]::OrdinalIgnoreCase) -lt 0) { + throw "ADE refused to remove a startup entry that is not owned by this channel." + } + Remove-ItemProperty -LiteralPath $runKey -Name $taskName -ErrorAction Stop + } + } + + foreach ($scheduledTaskName in @($taskName, $(if ($Channel -eq "stable") { "ADE Runtime" }))) { + if ([string]::IsNullOrWhiteSpace($scheduledTaskName)) { continue } + $task = Get-ScheduledTask -TaskName $scheduledTaskName -ErrorAction SilentlyContinue + if ($task) { + $actionText = [string](($task.Actions | ForEach-Object { "$($_.Execute) $($_.Arguments)" }) -join " ") + if ($actionText.IndexOf($launcherPath, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + Stop-ScheduledTask -InputObject $task -ErrorAction SilentlyContinue + Unregister-ScheduledTask -InputObject $task -Confirm:$false -ErrorAction Stop + } + } + } + Remove-Item -LiteralPath $pidPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $launcherPath -Force -ErrorAction SilentlyContinue +} + +$resolvedInstallDir = Resolve-NormalizedPath $InstallDir +$normalizedPackageChannel = $PackageChannel.Trim().ToLowerInvariant() +if (@("stable", "alpha", "beta") -notcontains $normalizedPackageChannel) { + throw "Unsupported ADE package channel: $PackageChannel" +} + +if (-not $SkipServiceRemoval) { + $normalizedAppExecutableName = [System.IO.Path]::GetFileName($AppExecutableName) + if ( + [string]::IsNullOrWhiteSpace($normalizedAppExecutableName) -or + -not [string]::Equals($normalizedAppExecutableName, $AppExecutableName, [System.StringComparison]::Ordinal) -or + -not $normalizedAppExecutableName.EndsWith(".exe", [System.StringComparison]::OrdinalIgnoreCase) + ) { + throw "The installer did not provide a valid ADE executable name." + } + + $homeName = if ($normalizedPackageChannel -eq "stable") { ".ade" } else { ".ade-$normalizedPackageChannel" } + $channelAdeHome = if ([string]::IsNullOrWhiteSpace($AdeHome)) { + Join-Path ([System.Environment]::GetFolderPath("UserProfile")) $homeName + } else { + Resolve-NormalizedPath $AdeHome + } + $appExe = Join-Path $resolvedInstallDir $normalizedAppExecutableName + $cliPath = Join-Path $resolvedInstallDir "resources\ade-cli\cli.cjs" + if ((Test-Path -LiteralPath $appExe -PathType Leaf) -and (Test-Path -LiteralPath $cliPath -PathType Leaf)) { + $electronRunAsNodePresent = Test-Path Env:ELECTRON_RUN_AS_NODE + $electronRunAsNode = $env:ELECTRON_RUN_AS_NODE + $disableCliInstallPresent = Test-Path Env:ADE_DISABLE_CLI_AUTO_INSTALL + $disableCliInstall = $env:ADE_DISABLE_CLI_AUTO_INSTALL + $packageChannelPresent = Test-Path Env:ADE_PACKAGE_CHANNEL + $previousPackageChannel = $env:ADE_PACKAGE_CHANNEL + $adeHomePresent = Test-Path Env:ADE_HOME + $previousAdeHome = $env:ADE_HOME + $nodePathPresent = Test-Path Env:NODE_PATH + $previousNodePath = $env:NODE_PATH + try { + $env:ELECTRON_RUN_AS_NODE = "1" + $env:ADE_DISABLE_CLI_AUTO_INSTALL = "1" + $env:ADE_PACKAGE_CHANNEL = $normalizedPackageChannel + $env:ADE_HOME = $channelAdeHome + $resourcesDir = Join-Path $resolvedInstallDir "resources" + $nodePathEntries = @( + (Join-Path $resourcesDir "app.asar.unpacked\node_modules") + (Join-Path $resourcesDir "app.asar\node_modules") + if (-not [string]::IsNullOrWhiteSpace($previousNodePath)) { $previousNodePath } + ) + $env:NODE_PATH = $nodePathEntries -join [System.IO.Path]::PathSeparator + $cleanupProcess = Start-Process ` + -FilePath $appExe ` + -ArgumentList @("`"$cliPath`"", "serve", "--uninstall-service") ` + -WindowStyle Hidden ` + -Wait ` + -PassThru + if ($cleanupProcess.ExitCode -ne 0) { + throw "The ADE background service cleanup command exited with code $($cleanupProcess.ExitCode)." + } + } finally { + Restore-EnvironmentValue "ELECTRON_RUN_AS_NODE" $electronRunAsNode $electronRunAsNodePresent + Restore-EnvironmentValue "ADE_DISABLE_CLI_AUTO_INSTALL" $disableCliInstall $disableCliInstallPresent + Restore-EnvironmentValue "ADE_PACKAGE_CHANNEL" $previousPackageChannel $packageChannelPresent + Restore-EnvironmentValue "ADE_HOME" $previousAdeHome $adeHomePresent + Restore-EnvironmentValue "NODE_PATH" $previousNodePath $nodePathPresent + } + } else { + Write-Warning "The packaged ADE executable or CLI is missing; removing only validated per-user startup state." + Remove-ChannelStartupWithoutPackagedCli $channelAdeHome $normalizedPackageChannel + } +} + +if ([string]::IsNullOrWhiteSpace($CliBinDir)) { + if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + throw "LOCALAPPDATA is unavailable; ADE cannot safely locate its terminal command." + } + $CliBinDir = Join-Path $env:LOCALAPPDATA "ADE\bin" +} + +$resolvedCliBinDir = Resolve-NormalizedPath $CliBinDir +$packagedCliDir = Resolve-NormalizedPath (Join-Path $resolvedInstallDir "resources\ade-cli\bin") +$expectedWrapperNames = @("ade.cmd") +if ($normalizedPackageChannel -ne "stable") { + $expectedWrapperNames += "ade-$normalizedPackageChannel.cmd" +} +if (Test-Path -LiteralPath $resolvedCliBinDir -PathType Container) { + foreach ($shim in Get-ChildItem -LiteralPath $resolvedCliBinDir -Filter "ade*.cmd" -File -ErrorAction Stop) { + if (Test-CliShimOwnedByInstall $shim.FullName $packagedCliDir $expectedWrapperNames) { + Remove-Item -LiteralPath $shim.FullName -Force -ErrorAction Stop + } + } +} + +$remainingAdeShims = @( + if (Test-Path -LiteralPath $resolvedCliBinDir -PathType Container) { + Get-ChildItem -LiteralPath $resolvedCliBinDir -Filter "ade*.cmd" -File -ErrorAction Stop + } +) + +if ($remainingAdeShims.Count -eq 0 -and -not $SkipUserPathUpdate) { + $currentPath = [System.Environment]::GetEnvironmentVariable("Path", "User") + $entries = if ([string]::IsNullOrWhiteSpace($currentPath)) { + @() + } else { + @($currentPath -split ";" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + $keptEntries = @($entries | Where-Object { + try { + (Resolve-NormalizedPath $_) -ne $resolvedCliBinDir + } catch { + $true + } + }) + if ($keptEntries.Count -ne $entries.Count) { + $nextPath = if ($keptEntries.Count -eq 0) { $null } else { $keptEntries -join ";" } + [System.Environment]::SetEnvironmentVariable("Path", $nextPath, "User") + Send-EnvironmentChanged + } +} + +if ($remainingAdeShims.Count -eq 0 -and (Test-Path -LiteralPath $resolvedCliBinDir -PathType Container)) { + $remainingFiles = @(Get-ChildItem -LiteralPath $resolvedCliBinDir -Force -ErrorAction Stop) + if ($remainingFiles.Count -eq 0) { + Remove-Item -LiteralPath $resolvedCliBinDir -Force -ErrorAction Stop + } +} + +Restore-FileAssociationDefaults $normalizedPackageChannel + +if (-not $SkipProtocolRemoval -and $normalizedPackageChannel -eq "stable" -and -not [string]::IsNullOrWhiteSpace($AppExecutableName)) { + Remove-OwnedStableProtocolRegistration (Join-Path $resolvedInstallDir $AppExecutableName) +} diff --git a/apps/desktop/scripts/windows-uninstall-cleanup.test.mjs b/apps/desktop/scripts/windows-uninstall-cleanup.test.mjs new file mode 100644 index 000000000..51b5c1da6 --- /dev/null +++ b/apps/desktop/scripts/windows-uninstall-cleanup.test.mjs @@ -0,0 +1,470 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { spawnSync } from "node:child_process"; + +const cleanupScript = path.resolve("scripts", "windows-uninstall-cleanup.ps1"); +const cliWrapperScript = path.resolve("scripts", "ade-cli-windows-wrapper.cmd"); +const installSetupScript = path.resolve("scripts", "windows-install-setup.ps1"); +const standaloneInstallerScript = path.resolve("..", "ade-cli", "scripts", "install-runtime.ps1"); + +function stripExtendedPathPrefix(value) { + if (value.startsWith("\\\\?\\UNC\\")) return `\\\\${value.slice(8)}`; + if (value.startsWith("\\\\?\\")) return value.slice(4); + return value; +} + +function realWindowsPath(value) { + return stripExtendedPathPrefix(fs.realpathSync.native(value)); +} + +function windowsPathIdentity(value) { + return path.win32.normalize(realWindowsPath(value)).replace(/[\\/]+$/, "").toLowerCase(); +} + +function shortWindowsPath(value) { + const script = [ + "Add-Type -TypeDefinition @'", + "using System;", + "using System.Runtime.InteropServices;", + "using System.Text;", + "public static class AdeTestPathInterop {", + ' [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]', + " public static extern uint GetShortPathName(string longPath, StringBuilder shortPath, uint bufferLength);", + "}", + "'@ | Out-Null", + "$buffer = New-Object System.Text.StringBuilder 32768", + "$length = [AdeTestPathInterop]::GetShortPathName($env:ADE_TEST_PATH, $buffer, [uint32]$buffer.Capacity)", + "if ($length -eq 0) { exit 1 }", + "[Console]::Out.Write($buffer.ToString())", + ].join("\r\n"); + const encodedScript = Buffer.from(script, "utf16le").toString("base64"); + const result = spawnSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + encodedScript, + ], { + encoding: "utf8", + env: { ...process.env, ADE_TEST_PATH: value }, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const resolved = result.stdout.trim(); + assert.notEqual(resolved, "", "PowerShell did not return a path representation"); + return resolved; +} + +test("Windows standalone installer path normalizer is executable PowerShell", { + skip: process.platform !== "win32", +}, () => { + const probe = [ + "$tokens = $null", + "$errors = $null", + "$ast = [Management.Automation.Language.Parser]::ParseFile($env:ADE_TEST_SCRIPT, [ref]$tokens, [ref]$errors)", + "if ($errors.Count -ne 0) { exit 2 }", + "$functions = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Remove-TrailingDirectorySeparators' }, $true))", + "if ($functions.Count -ne 1) { exit 3 }", + ].join("; "); + const result = spawnSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + probe, + ], { + encoding: "utf8", + env: { ...process.env, ADE_TEST_SCRIPT: standaloneInstallerScript }, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); +}); + +test("Windows uninstall cleanup removes only CLI shims owned by this installation", { + skip: process.platform !== "win32", +}, (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade uninstall cleanup ")); + t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); + const installDir = path.join(tempRoot, "install & source"); + const packagedCliDir = path.join(installDir, "resources", "ade-cli", "bin"); + const cliBinDir = path.join(tempRoot, "user bin"); + fs.mkdirSync(packagedCliDir, { recursive: true }); + fs.mkdirSync(cliBinDir, { recursive: true }); + const longInstallDir = realWindowsPath(installDir); + const shortPackagedCliDir = shortWindowsPath(packagedCliDir); + fs.writeFileSync( + path.join(cliBinDir, "ade.cmd"), + `@echo off\r\n"${path.join(shortPackagedCliDir, "ade.cmd")}" %*\r\n`, + ); + fs.writeFileSync( + path.join(cliBinDir, "ade-alpha.cmd"), + `@echo off\r\n"${path.join(packagedCliDir, "ade.cmd")}" %*\r\n`, + ); + fs.writeFileSync( + path.join(cliBinDir, "ade-beta.cmd"), + '@echo off\r\n"C:\\Other ADE\\ade-beta.cmd" %*\r\n', + ); + + const result = spawnSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + cleanupScript, + "-InstallDir", + longInstallDir, + "-CliBinDir", + cliBinDir, + "-SkipServiceRemoval", + "-SkipUserPathUpdate", + ], { encoding: "utf8" }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(fs.existsSync(path.join(cliBinDir, "ade.cmd")), false); + assert.equal(fs.existsSync(path.join(cliBinDir, "ade-alpha.cmd")), false); + assert.equal(fs.existsSync(path.join(cliBinDir, "ade-beta.cmd")), true); +}); + +test("Windows stable uninstall reaches protocol cleanup without treating it as C# source", { + skip: process.platform !== "win32", +}, (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade stable protocol cleanup ")); + t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); + const installDir = path.join(tempRoot, "ADE"); + const cliBinDir = path.join(tempRoot, "empty user bin"); + fs.mkdirSync(installDir, { recursive: true }); + fs.mkdirSync(cliBinDir, { recursive: true }); + + const result = spawnSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + cleanupScript, + "-InstallDir", + installDir, + "-AppExecutableName", + "ADE.exe", + "-PackageChannel", + "stable", + "-CliBinDir", + cliBinDir, + "-SkipServiceRemoval", + "-SkipUserPathUpdate", + ], { encoding: "utf8" }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); +}); + +test("Windows uninstall still cleans a corrupted product whose executable is missing", { + skip: process.platform !== "win32", +}, (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade missing executable cleanup ")); + t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); + const installDir = path.join(tempRoot, "ADE Beta"); + const cliBinDir = path.join(tempRoot, "empty user bin"); + const adeHome = path.join(tempRoot, ".ade-beta"); + fs.mkdirSync(installDir, { recursive: true }); + fs.mkdirSync(cliBinDir, { recursive: true }); + fs.mkdirSync(adeHome, { recursive: true }); + + const result = spawnSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + cleanupScript, + "-InstallDir", + installDir, + "-AppExecutableName", + "ADE Beta.exe", + "-PackageChannel", + "beta", + "-AdeHome", + adeHome, + "-CliBinDir", + cliBinDir, + "-SkipUserPathUpdate", + ], { encoding: "utf8" }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(`${result.stdout}\n${result.stderr}`, /packaged ADE executable or CLI is missing/i); +}); + +test("Windows bundled CLI wrapper resolves the Beta executable and identity", { + skip: process.platform !== "win32", +}, (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade beta wrapper ")); + t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); + const resourcesDir = path.join(tempRoot, "resources"); + const cliRoot = path.join(resourcesDir, "ade-cli"); + const binDir = path.join(cliRoot, "bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.copyFileSync(cliWrapperScript, path.join(binDir, "ade-beta.cmd")); + fs.copyFileSync(process.execPath, path.join(tempRoot, "ADE Beta.exe")); + fs.writeFileSync(path.join(cliRoot, "channel"), "beta\r\n"); + const probe = path.join(tempRoot, "probe.cjs"); + fs.writeFileSync(probe, [ + "process.stdout.write(JSON.stringify({", + " channel: process.env.ADE_PACKAGE_CHANNEL,", + " appName: process.env.ADE_DESKTOP_APP_NAME,", + " argv: process.argv.slice(2),", + "}));", + ].join("\n")); + + const wrapperPath = path.join(binDir, "ade-beta.cmd"); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", [ + "/d", + "/s", + "/c", + `""${wrapperPath}" probe-argument"`, + ], { + encoding: "utf8", + env: { ...process.env, ADE_CLI_JS: probe }, + windowsVerbatimArguments: true, + }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.deepEqual(JSON.parse(result.stdout), { + channel: "beta", + appName: "ADE Beta", + argv: ["probe-argument"], + }); +}); + +test("Windows install setup compensates shim and startup state when service registration fails", { + skip: process.platform !== "win32", +}, (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade setup rollback ")); + t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); + const installDir = path.join(tempRoot, "ADE Beta"); + const cliRoot = path.join(installDir, "resources", "ade-cli"); + const cliBin = path.join(cliRoot, "bin"); + const localAppData = path.join(tempRoot, "local app data"); + const cleanupMarker = path.join(tempRoot, "cleanup-ran.txt"); + fs.mkdirSync(cliBin, { recursive: true }); + fs.mkdirSync(localAppData, { recursive: true }); + fs.copyFileSync(process.execPath, path.join(installDir, "ADE Beta.exe")); + fs.copyFileSync(cleanupScript, path.join(cliRoot, "windows-uninstall-cleanup.ps1")); + const cliWrapper = path.join(cliBin, "ade-beta.cmd"); + fs.writeFileSync(cliWrapper, [ + "@echo off", + 'if /I "%~2"=="--service-status" (', + ' echo {"installed":false,"running":false}', + " exit /b 0", + ")", + 'if /I "%~2"=="--uninstall-service" (', + ` echo cleanup> "${cleanupMarker}"`, + " exit /b 0", + ")", + "exit /b 17", + ].join("\r\n")); + fs.writeFileSync(path.join(cliRoot, "cli.cjs"), ""); + fs.writeFileSync(path.join(cliRoot, "install-path.cmd"), [ + "@echo off", + 'if not exist "%~dp1" mkdir "%~dp1"', + '> "%~1" echo @echo off', + `>> "%~1" echo "${cliWrapper}" %%*`, + "exit /b 0", + ].join("\r\n")); + + const result = spawnSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + installSetupScript, + "-InstallDir", + installDir, + "-AppExecutableName", + "ADE Beta.exe", + "-PackageChannel", + "beta", + ], { + encoding: "utf8", + env: { + ...process.env, + LOCALAPPDATA: localAppData, + USERPROFILE: path.join(tempRoot, "user profile"), + }, + }); + + assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /brain startup installer exited with code 17/i); + assert.equal(fs.existsSync(path.join(localAppData, "ADE", "bin", "ade-beta.cmd")), false); + assert.equal(fs.existsSync(cleanupMarker), true); +}); + +test("Windows failed repair restores the previous shim and startup service", { + skip: process.platform !== "win32", +}, (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade repair rollback ")); + t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); + const installDir = path.join(tempRoot, "ADE Beta"); + const cliRoot = path.join(installDir, "resources", "ade-cli"); + const cliBin = path.join(cliRoot, "bin"); + const localAppData = path.join(tempRoot, "local app data"); + const targetShim = path.join(localAppData, "ADE", "bin", "ade-beta.cmd"); + const attemptMarker = path.join(tempRoot, "install-attempted.txt"); + const restoredMarker = path.join(tempRoot, "service-restored.txt"); + const previousShim = "@echo off\r\necho previous healthy shim\r\n"; + fs.mkdirSync(cliBin, { recursive: true }); + fs.mkdirSync(path.dirname(targetShim), { recursive: true }); + fs.copyFileSync(process.execPath, path.join(installDir, "ADE Beta.exe")); + fs.copyFileSync(cleanupScript, path.join(cliRoot, "windows-uninstall-cleanup.ps1")); + fs.writeFileSync(targetShim, previousShim); + const cliWrapper = path.join(cliBin, "ade-beta.cmd"); + fs.writeFileSync(cliWrapper, `@echo off\r\n"${process.execPath}" "%~dp0..\\cli.cjs" %*\r\nexit /b %ERRORLEVEL%\r\n`); + fs.writeFileSync(path.join(cliRoot, "cli.cjs"), [ + 'const fs = require("node:fs");', + 'if (process.argv.includes("--service-status")) {', + ' process.stdout.write(JSON.stringify({ installed: true, running: false }));', + '} else if (process.argv.includes("--install-service")) {', + ` if (!fs.existsSync(${JSON.stringify(attemptMarker)})) {`, + ` fs.writeFileSync(${JSON.stringify(attemptMarker)}, "attempted");`, + " process.exitCode = 17;", + " } else {", + ` fs.writeFileSync(${JSON.stringify(restoredMarker)}, "restored");`, + " }", + "}", + ].join("\n")); + fs.writeFileSync(path.join(cliRoot, "install-path.cmd"), [ + "@echo off", + 'if not exist "%~dp1" mkdir "%~dp1"', + '> "%~1" echo @echo off', + `>> "%~1" echo "${cliWrapper}" %%*`, + "exit /b 0", + ].join("\r\n")); + + const result = spawnSync("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", installSetupScript, + "-InstallDir", installDir, + "-AppExecutableName", "ADE Beta.exe", + "-PackageChannel", "beta", + ], { + encoding: "utf8", + env: { + ...process.env, + LOCALAPPDATA: localAppData, + USERPROFILE: path.join(tempRoot, "user profile"), + }, + }); + + assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /brain startup installer exited with code 17/i); + assert.equal(fs.readFileSync(targetShim, "utf8"), previousShim); + assert.equal(fs.existsSync(restoredMarker), true); +}); + +test("Windows uninstall cleanup uses the packaged executable and channel identity", { + skip: process.platform !== "win32", +}, (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade beta uninstall ")); + t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); + const installDir = path.join(tempRoot, "ADE Beta"); + const cliRoot = path.join(installDir, "resources", "ade-cli"); + const cliBinDir = path.join(tempRoot, "empty user bin"); + const resultPath = path.join(tempRoot, "service-cleanup.json"); + const packagedCliBin = path.join(cliRoot, "bin"); + fs.mkdirSync(packagedCliBin, { recursive: true }); + fs.mkdirSync(cliBinDir, { recursive: true }); + fs.writeFileSync( + path.join(cliBinDir, "ade-beta.cmd"), + `@echo off\r\n"${path.join(packagedCliBin, "ade-beta.cmd")}" %*\r\n`, + ); + + const unpackedNodeModules = path.join(installDir, "resources", "app.asar.unpacked", "node_modules"); + const packedNodeModules = path.join(installDir, "resources", "app.asar", "node_modules"); + fs.mkdirSync(unpackedNodeModules, { recursive: true }); + fs.mkdirSync(packedNodeModules, { recursive: true }); + + const appExecutableName = "ADE Beta.exe"; + fs.copyFileSync(process.execPath, path.join(installDir, appExecutableName)); + fs.writeFileSync(path.join(cliRoot, "cli.cjs"), [ + 'const fs = require("node:fs");', + `fs.writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({`, + " argv: process.argv.slice(2),", + " packageChannel: process.env.ADE_PACKAGE_CHANNEL,", + " adeHome: process.env.ADE_HOME,", + " nodePath: process.env.NODE_PATH,", + "}));", + ].join("\n")); + + const result = spawnSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + cleanupScript, + "-InstallDir", + shortWindowsPath(installDir), + "-AppExecutableName", + appExecutableName, + "-PackageChannel", + "beta", + "-CliBinDir", + cliBinDir, + "-SkipUserPathUpdate", + ], { + encoding: "utf8", + env: { + ...process.env, + ADE_PACKAGE_CHANNEL: "alpha", + ADE_HOME: "C:\\wrong-channel-home", + NODE_PATH: "C:\\existing-node-modules", + }, + }); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const observed = JSON.parse(fs.readFileSync(resultPath, "utf8")); + assert.deepEqual(observed.argv, ["serve", "--uninstall-service"]); + assert.equal(observed.packageChannel, "beta"); + assert.equal(path.win32.basename(observed.adeHome), ".ade-beta"); + const observedNodePath = observed.nodePath.split(path.delimiter); + assert.deepEqual(observedNodePath.slice(0, 2).map(windowsPathIdentity), [ + windowsPathIdentity(unpackedNodeModules), + windowsPathIdentity(packedNodeModules), + ]); + assert.equal(observedNodePath[2], "C:\\existing-node-modules"); + assert.equal(fs.existsSync(path.join(cliBinDir, "ade-beta.cmd")), false); +}); + +test("Windows uninstall cleanup reports the packaged executable exit code", { + skip: process.platform !== "win32", +}, (t) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade failed uninstall ")); + t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true })); + const installDir = path.join(tempRoot, "ADE"); + const cliRoot = path.join(installDir, "resources", "ade-cli"); + const cliBinDir = path.join(tempRoot, "empty user bin"); + fs.mkdirSync(cliRoot, { recursive: true }); + fs.mkdirSync(cliBinDir, { recursive: true }); + + const appExecutableName = "ADE.exe"; + fs.copyFileSync(process.execPath, path.join(installDir, appExecutableName)); + fs.writeFileSync(path.join(cliRoot, "cli.cjs"), "process.exitCode = 19;\n"); + + const result = spawnSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + cleanupScript, + "-InstallDir", + installDir, + "-AppExecutableName", + appExecutableName, + "-CliBinDir", + cliBinDir, + "-SkipUserPathUpdate", + ], { encoding: "utf8" }); + + assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /cleanup command exited with code 19/i); +}); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index c35d6863e..2ea4b2dd5 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -266,6 +266,7 @@ import { LocalRuntimeConnectionPool } from "./services/localRuntime/localRuntime import { createSyncService } from "./services/sync/syncService"; import { blockPackagedLaunchForCrossChannelSyncConflict } from "./services/sync/packagedSyncHostLaunchGate"; import { createAutoUpdateService } from "./services/updates/autoUpdateService"; +import { DEFAULT_RELEASE_REPOSITORY } from "./services/updates/autoUpdateVersions"; import { cleanupStaleTempArtifacts } from "./services/runtime/tempCleanupService"; import type { Logger } from "./services/logging/logger"; import { resolveDesktopUserDataPath, resolveElectronAppDataPath } from "./desktopUserDataPath"; @@ -276,6 +277,31 @@ const AUTO_UPDATER_CACHE_DIR_NAME = "ade-desktop-updater"; type AdePackageChannel = "alpha" | "beta"; +function normalizeAdeReleaseRepository(value: unknown): string | null { + const normalized = typeof value === "string" + ? value.trim().replace(/^\/+|\/+$/g, "") + : ""; + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized) ? normalized : null; +} + +function readBundledAdeReleaseRepository(): string { + try { + const packageJsonPath = path.join(app.getAppPath(), "package.json"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + adeReleaseRepository?: unknown; + }; + const bundledRepository = normalizeAdeReleaseRepository(packageJson.adeReleaseRepository); + if (bundledRepository) return bundledRepository; + } catch { + // Older packages use the upstream repository default. + } + if (!app.isPackaged) { + return normalizeAdeReleaseRepository(process.env.ADE_RELEASE_REPOSITORY) + ?? DEFAULT_RELEASE_REPOSITORY; + } + return DEFAULT_RELEASE_REPOSITORY; +} + function normalizeAdePackageChannel(value: unknown): AdePackageChannel | null { const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; return normalized === "alpha" || normalized === "beta" ? normalized : null; @@ -316,6 +342,7 @@ function applyPackagedChannelDefaults(): void { } applyPackagedChannelDefaults(); +const packagedReleaseRepository = readBundledAdeReleaseRepository(); function configureDesktopUserDataPath(): void { const appDataPath = (() => { @@ -2240,6 +2267,7 @@ app.whenReady().then(async () => { rollbackQuitAndInstall: rollbackAutoUpdateInstall, getRuntimeActivitySummary: () => localRuntimePool.activitySummary(), productAnalyticsService, + releaseRepository: packagedReleaseRepository, forceQuit: () => { for (const win of BrowserWindow.getAllWindows()) { try { @@ -7064,6 +7092,7 @@ app.whenReady().then(async () => { closeCurrentProject, closeProjectByPath, globalStatePath, + releaseRepository: packagedReleaseRepository, builtInBrowserService, productAnalyticsService, publishAttentionNotchSnapshot: (snapshot: AttentionSnapshot) => { diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.test.ts b/apps/desktop/src/main/packagedRuntimeSmoke.test.ts index d51ba01c9..82dfd88db 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.test.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.test.ts @@ -3,7 +3,9 @@ import { classifyClaudeStartupFailure, getClaudeNativeBinaryFileName, getClaudeNativeBinaryPackageName, + probeCrsqliteExtension, } from "./packagedRuntimeSmokeShared"; +import path from "node:path"; describe("packagedRuntimeSmoke", () => { it("classifies a missing bundled Claude binary distinctly", () => { @@ -46,4 +48,11 @@ describe("packagedRuntimeSmoke", () => { expect(getClaudeNativeBinaryFileName("win32")).toBe("claude.exe"); expect(getClaudeNativeBinaryFileName("darwin")).toBe("claude"); }); + + it.skipIf(process.platform !== "win32")("loads the packaged Windows CR-SQLite extension and records a CRR change", () => { + const result = probeCrsqliteExtension( + path.resolve(process.cwd(), "vendor", "crsqlite", "win32-x64", "crsqlite.dll"), + ); + expect(result).toEqual({ ok: true, changeRows: 1 }); + }); }); diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.ts b/apps/desktop/src/main/packagedRuntimeSmoke.ts index 8cdaa7e9b..2cd4c1a76 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.ts @@ -1,10 +1,13 @@ import os from "node:os"; +import path from "node:path"; import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { resolveClaudeCodeExecutable } from "./services/ai/claudeCodeExecutable"; import { resolveCodexExecutable } from "./services/ai/codexExecutable"; +import { resolveDroidExecutable } from "./services/ai/droidExecutable"; import { resolveOpenCodeBinary } from "./services/opencode/openCodeBinaryManager"; import { classifyClaudeStartupFailure, + probeCrsqliteExtension, type ClaudeStartupProbeResult, } from "./packagedRuntimeSmokeShared"; @@ -124,10 +127,28 @@ async function probeClaudeStartup(): Promise { async function main(): Promise { const pty = await import("node-pty"); const claude = await import("@anthropic-ai/claude-agent-sdk"); + const cursor = await import("@cursor/sdk"); + const droid = await import("@factory/droid-sdk"); const claudeExecutable = resolveClaudeCodeExecutable(); const codexExecutable = resolveCodexExecutable(); + const droidExecutable = resolveDroidExecutable(); const openCodeExecutable = resolveOpenCodeBinary(); + const cursorNativePackageRoot = path.resolve( + __dirname, + "..", + "..", + "node_modules", + "@cursor", + "sdk-win32-x64", + ); + const cursorNativeRgPath = path.join(cursorNativePackageRoot, "bin", "rg.exe"); + const cursorNativeSandboxPath = path.join(cursorNativePackageRoot, "bin", "cursorsandbox.exe"); const ptyProbe = await probePty(); + const crsqliteProbe = process.platform === "win32" + ? probeCrsqliteExtension( + path.resolve(__dirname, "..", "..", "vendor", "crsqlite", "win32-x64", "crsqlite.dll"), + ) + : null; const claudeStartup = await probeClaudeStartup(); process.stdout.write(JSON.stringify({ @@ -140,10 +161,17 @@ async function main(): Promise { codexExecutable: typeof resolveCodexExecutable, codexExecutablePath: codexExecutable.path, codexExecutableSource: codexExecutable.source, + cursorSdkCreateAgentPlatform: typeof cursor.createAgentPlatform, + cursorNativeRgPath, + cursorNativeSandboxPath, + droidSdkCreateSession: typeof droid.createSession, + droidExecutablePath: droidExecutable.path, + droidExecutableSource: droidExecutable.source, openCodeExecutable: typeof resolveOpenCodeBinary, openCodeExecutablePath: openCodeExecutable.path, openCodeExecutableSource: openCodeExecutable.source, ptyProbe, + crsqliteProbe, })); } diff --git a/apps/desktop/src/main/packagedRuntimeSmokeShared.ts b/apps/desktop/src/main/packagedRuntimeSmokeShared.ts index cac786e24..89db60433 100644 --- a/apps/desktop/src/main/packagedRuntimeSmokeShared.ts +++ b/apps/desktop/src/main/packagedRuntimeSmokeShared.ts @@ -36,6 +36,42 @@ export type ClaudeStartupProbeResult = | { state: "binary-missing"; message: string } | { state: "runtime-failed"; message: string }; +export type CrsqliteProbeResult = { + ok: true; + changeRows: number; +}; + +export function probeCrsqliteExtension(extensionPath: string): CrsqliteProbeResult { + // Keep node:sqlite lazy and opaque to esbuild. With the desktop bundle's + // Node 18 target, a top-level literal require("node:sqlite") is rewritten + // to require("sqlite"), which crashes Electron before the smoke probe runs. + const nodeSqliteSpecifier = ["node", "sqlite"].join(":"); + const { DatabaseSync } = require(nodeSqliteSpecifier) as { + DatabaseSync: new ( + path: string, + options?: { allowExtension?: boolean }, + ) => import("node:sqlite").DatabaseSync; + }; + const db = new DatabaseSync(":memory:", { allowExtension: true }); + try { + db.enableLoadExtension(true); + db.loadExtension(extensionPath); + db.exec("create table ade_packaged_crr_probe (id text primary key not null, value text)"); + db.prepare("select crsql_as_crr(?)").get("ade_packaged_crr_probe"); + db.prepare("insert into ade_packaged_crr_probe (id, value) values (?, ?)").run("probe", "ready"); + const row = db.prepare( + "select count(*) as count from crsql_changes where [table] = ?", + ).get<{ count: number | bigint }>("ade_packaged_crr_probe"); + const changeRows = Number(row?.count ?? 0); + if (changeRows < 1) { + throw new Error("CR-SQLite loaded but did not record the packaged-runtime probe change."); + } + return { ok: true, changeRows }; + } finally { + db.close(); + } +} + export function getClaudeNativeBinaryPackageName( platform: NodeJS.Platform = process.platform, arch: string = process.arch, diff --git a/apps/desktop/src/main/services/account/accountBridge.ts b/apps/desktop/src/main/services/account/accountBridge.ts index 1c3f75159..1fb81f55f 100644 --- a/apps/desktop/src/main/services/account/accountBridge.ts +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -32,6 +32,7 @@ import type { AdeAccountMachinesResult, AdeAccountPairMachineProgress, AdeAccountLoginPoll, + AdeAccountSessionReadState, AdeAccountStatus, } from "../../../shared/types"; import { @@ -115,6 +116,7 @@ function resolveDirectoryBaseUrl(projectRoot: string | null): string | null { function toAccountStatus( status: AccountAuthStatus, configured: boolean, + sessionReadState?: AdeAccountSessionReadState, ): AdeAccountStatus { return { signedIn: status.signedIn, @@ -125,6 +127,7 @@ function toAccountStatus( provider: status.provider ?? null, imageUrl: status.imageUrl ?? null, configured, + ...(sessionReadState ? { sessionReadState } : {}), }; } @@ -229,7 +232,17 @@ export function createAccountBridge(options: AccountBridgeOptions): AccountBridg }; return { - status: () => toAccountStatus(service().getStatus(), configured()), + status: () => { + const accountService = service(); + // Read the state alongside the status: `signedIn: false` with an + // "unreadable" session is a failed decrypt, not a sign-out, and the + // renderer must be able to tell them apart. + const status = accountService.getStatus(); + // Optional call: a runtime that predates the split simply reports no read + // state, and the renderer then falls back to its previous behaviour. + const readState = accountService.getSessionReadState?.(); + return toAccountStatus(status, configured(), readState); + }, startLogin: () => { // Prioritize the active project's CLERK_* secrets for config resolution. diff --git a/apps/desktop/src/main/services/ai/authDetector.test.ts b/apps/desktop/src/main/services/ai/authDetector.test.ts index 352dbef7c..5fa5ff682 100644 --- a/apps/desktop/src/main/services/ai/authDetector.test.ts +++ b/apps/desktop/src/main/services/ai/authDetector.test.ts @@ -13,6 +13,18 @@ const reportProviderRuntimeAuthFailureMock = vi.fn(); const reportProviderRuntimeFailureMock = vi.fn(); const reportProviderRuntimeReadyMock = vi.fn(); +vi.mock("node:path", async () => { + const actual = await vi.importActual("node:path"); + const dynamicDefault = new Proxy({} as typeof actual, { + get(_target, property) { + const implementation = process.platform === "win32" ? actual.win32 : actual.posix; + const value = implementation[property as keyof typeof implementation]; + return typeof value === "function" ? value.bind(implementation) : value; + }, + }); + return { ...actual, default: dynamicDefault }; +}); + /** Helper: create a fake ChildProcess that immediately emits close with the given result. */ function fakeChild(result: { status: number | null; stdout?: string; stderr?: string }) { const child = new EventEmitter() as any; @@ -40,6 +52,20 @@ function fakeError() { return child; } +function commandBasename(command: string): string { + return command.replace(/\\/g, "/").split("/").pop() ?? command; +} + +function withExecutableMode(stat: fs.Stats): fs.Stats { + return new Proxy(stat, { + get(target, property, receiver) { + if (property === "mode") return target.mode | 0o111; + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); return { @@ -131,7 +157,7 @@ describe("authDetector", () => { if (args[0] === "claude") return fakeChild({ status: 0, stdout: "/usr/local/bin/claude\n" }); return fakeChild({ status: 1 }); } - if ((command === "claude" || command.endsWith("/claude")) && args[0] === "auth") { + if (commandBasename(command) === "claude" && args[0] === "auth") { return fakeChild({ status: 1, stderr: "Not logged in. Run `claude auth login`." }); } return fakeChild({ status: 1 }); @@ -159,7 +185,7 @@ describe("authDetector", () => { if (args[0] === "claude") return fakeChild({ status: 0, stdout: "/usr/local/bin/claude\n" }); return fakeChild({ status: 1 }); } - if ((command === "claude" || command.endsWith("/claude")) && args[0] === "auth") { + if (commandBasename(command) === "claude" && args[0] === "auth") { throw new Error("auth probe should not run"); } return fakeChild({ status: 1 }); @@ -181,6 +207,40 @@ describe("authDetector", () => { })).toBe(false); }); + it("detects and probes a Windows cursor-agent.cmd outside PATH", async () => { + setPlatform("win32"); + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-auth-win-")); + const npmBin = path.join(tempHomeDir, "npm"); + const cursorAgentPath = path.join(npmBin, "cursor-agent.cmd"); + fs.mkdirSync(npmBin, { recursive: true }); + fs.writeFileSync(cursorAgentPath, "@echo off\r\n", "utf8"); + process.env.APPDATA = tempHomeDir; + process.env.PATH = "C:\\Windows\\System32"; + process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; + + spawnMock.mockImplementation((command: string, args: string[] = []) => { + const commandLine = args.join(" ").toLowerCase(); + if (command.toLowerCase().endsWith("cmd.exe") && commandLine.includes("cursor-agent.cmd")) { + if (commandLine.includes("--version")) return fakeChild({ status: 0, stdout: "1.0.0\n" }); + if (commandLine.includes("status")) { + return fakeChild({ status: 0, stdout: '{"authenticated":true,"plan":"pro"}\n' }); + } + } + if (command === "where") return fakeChild({ status: 1 }); + return fakeError(); + }); + + const statuses = await detectCliAuthStatuses({ force: true }); + expect(statuses.find((entry) => entry.cli === "cursor")).toMatchObject({ + cli: "cursor", + installed: true, + authenticated: true, + verified: true, + paidPlan: true, + }); + expect(statuses.find((entry) => entry.cli === "cursor")?.path?.toLowerCase()).toBe(cursorAgentPath.toLowerCase()); + }); + it("merges config, store, env, and local endpoint auth sources", async () => { getAllApiKeysMock.mockReturnValue({ anthropic: "store-anthropic", @@ -199,7 +259,7 @@ describe("authDetector", () => { if (args[0] === "claude") return fakeChild({ status: 0, stdout: "/usr/local/bin/claude\n" }); return fakeChild({ status: 1 }); } - if ((command === "claude" || command.endsWith("/claude")) && args[0] === "auth") { + if (commandBasename(command) === "claude" && args[0] === "auth") { return fakeChild({ status: 0, stdout: "Authenticated as test-user\n" }); } return fakeChild({ status: 1 }); @@ -278,7 +338,7 @@ describe("authDetector", () => { ); }); - it("treats droid exec list-tools as a valid authenticated probe", async () => { + it("does not treat droid exec list-tools as proof of authentication", async () => { tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-droid-auth-")); process.env.HOME = tempHomeDir; // Create a fake droid binary in a known bin dir so resolveDroidExecutable @@ -294,21 +354,18 @@ describe("authDetector", () => { spawnMock.mockImplementation((command: string, args: string[] = []) => { if (args[0] === "--version") { - if (command === "droid" || command.endsWith("/droid")) return fakeChild({ status: 0, stdout: "0.70.0\n" }); + if (commandBasename(command) === "droid") return fakeChild({ status: 0, stdout: "0.70.0\n" }); return fakeError(); } if (command === "which") { if (args[0] === "droid") return fakeChild({ status: 0, stdout: `${fakeDroidPath}\n` }); return fakeChild({ status: 1 }); } - if ((command === "droid" || command.endsWith("/droid")) && args[0] === "exec" && args[1] === "--list-tools") { - return fakeChild({ status: 0, stdout: "Available tools for Claude Opus 4.6\n" }); - } - if ((command === "droid" || command.endsWith("/droid")) && args[0] === "account") { - return fakeChild({ status: 1, stderr: "unknown command 'account'\n" }); - } - if ((command === "droid" || command.endsWith("/droid")) && args[0] === "whoami") { - return fakeChild({ status: 1, stderr: "unknown command 'whoami'\n" }); + // Faithful to droid v0.186.0: `exec --list-tools` exits 0 and prints the + // local tool policy with no Factory account at all, so it says nothing + // about authentication. + if (commandBasename(command) === "droid" && args[0] === "exec" && args[1] === "--list-tools") { + return fakeChild({ status: 0, stdout: "Available tools for Opus 5\nAutonomy: read-only\n" }); } return fakeChild({ status: 1 }); }); @@ -316,13 +373,25 @@ describe("authDetector", () => { const statuses = await detectCliAuthStatuses({ force: true }); const droid = statuses.find((entry) => entry.cli === "droid"); + // Installed, auth unknown — not "authenticated and verified". The real CLI + // in this state answers `droid exec "say hi"` with "Authentication failed." expect(droid).toEqual({ cli: "droid", installed: true, path: fakeDroidPath, - authenticated: true, - verified: true, + authenticated: false, + verified: false, }); + + // `whoami` and `account status` are not subcommands on v0.186.0, so droid + // took each as a prompt and booted the interactive TUI until the spawn + // timeout. Nothing may spawn them again. + const droidArgs = spawnMock.mock.calls + .filter(([command]) => commandBasename(String(command)) === "droid") + .map(([, args]) => ((args ?? []) as string[]).join(" ")); + expect(droidArgs).not.toContain("whoami"); + expect(droidArgs).not.toContain("account status"); + expect(droidArgs).not.toContain("exec --list-tools"); }); it("skips deep Droid auth probes during default detection without stored credentials", async () => { @@ -333,31 +402,40 @@ describe("authDetector", () => { const fakeDroidPath = path.join(droidBinDir, "droid"); fs.writeFileSync(fakeDroidPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); process.env.PATH = ""; + const realStatSync = fs.statSync.bind(fs); + const statSpy = vi.spyOn(fs, "statSync").mockImplementation(((candidatePath: fs.PathLike, options?: fs.StatOptions) => { + const stat = realStatSync(candidatePath, options as fs.StatOptions | undefined); + return String(candidatePath) === fakeDroidPath ? withExecutableMode(stat) : stat; + }) as typeof fs.statSync); - spawnMock.mockImplementation((command: string, _args: string[] = []) => { - if (command === "which") { - return fakeChild({ status: 1 }); - } - return fakeError(); - }); + try { + spawnMock.mockImplementation((command: string, _args: string[] = []) => { + if (command === "which") { + return fakeChild({ status: 1 }); + } + return fakeError(); + }); - const statuses = await detectCliAuthStatuses(); - const droid = statuses.find((entry) => entry.cli === "droid"); + const statuses = await detectCliAuthStatuses(); + const droid = statuses.find((entry) => entry.cli === "droid"); - expect(droid).toEqual({ - cli: "droid", - installed: true, - path: fakeDroidPath, - authenticated: false, - verified: false, - }); - const droidDeepProbeCalls = spawnMock.mock.calls.filter(([command, args]) => { - const commandText = String(command); - const argv = Array.isArray(args) ? args as string[] : []; - return (commandText === "droid" || commandText.endsWith("/droid")) - && (argv[0] === "exec" || argv[0] === "account" || argv[0] === "whoami"); - }); - expect(droidDeepProbeCalls).toHaveLength(0); + expect(droid).toEqual({ + cli: "droid", + installed: true, + path: fakeDroidPath, + authenticated: false, + verified: false, + }); + const droidDeepProbeCalls = spawnMock.mock.calls.filter(([command, args]) => { + const commandText = String(command); + const argv = Array.isArray(args) ? args as string[] : []; + return commandBasename(commandText) === "droid" + && (argv[0] === "exec" || argv[0] === "account" || argv[0] === "whoami"); + }); + expect(droidDeepProbeCalls).toHaveLength(0); + } finally { + statSpy.mockRestore(); + } }); it("does not report openai-compatible local providers when no models are loaded", async () => { @@ -427,7 +505,7 @@ describe("authDetector", () => { if (args[0] === "claude") return fakeChild({ status: 0, stdout: "/usr/local/bin/claude\n" }); return fakeChild({ status: 1 }); } - if (command === "claude" || command.endsWith("/claude")) { + if (commandBasename(command) === "claude") { return fakeChild({ status: 1, stderr: "unknown command 'auth'" }); } return fakeChild({ status: 1 }); @@ -453,12 +531,13 @@ describe("authDetector", () => { const realStatSync = fs.statSync.bind(fs); const statSpy = vi.spyOn(fs, "statSync").mockImplementation(((candidatePath: fs.PathLike, options?: fs.StatOptions) => { const resolved = String(candidatePath); - if (resolved.endsWith("/codex") && resolved !== preferredCodexPath) { + if (commandBasename(resolved) === "codex" && resolved !== preferredCodexPath) { const error = new Error(`ENOENT: no such file or directory, stat '${resolved}'`) as NodeJS.ErrnoException; error.code = "ENOENT"; throw error; } - return realStatSync(candidatePath, options as fs.StatOptions | undefined); + const stat = realStatSync(candidatePath, options as fs.StatOptions | undefined); + return resolved === preferredCodexPath ? withExecutableMode(stat) : stat; }) as typeof fs.statSync); try { @@ -471,7 +550,7 @@ describe("authDetector", () => { if (command === "which") { return fakeChild({ status: 1 }); } - if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") { + if (commandBasename(command) === "codex" && args[0] === "login" && args[1] === "status") { return fakeChild({ status: 0, stdout: "Authenticated as test-user\n" }); } return fakeChild({ status: 1 }); @@ -519,7 +598,7 @@ describe("authDetector", () => { } return fakeChild({ status: 1 }); } - if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") { + if (commandBasename(command) === "codex" && args[0] === "login" && args[1] === "status") { return fakeChild({ status: 0, stdout: "Logged in using ChatGPT\n" }); } return fakeChild({ status: 1 }); diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index 7508b1fde..fea0510d8 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -12,6 +12,7 @@ import { setPathEnvValue, } from "./cliExecutableResolver"; import { getLocalProviderDefaultEndpoint, type LocalProviderFamily } from "../../../shared/modelRegistry"; +import { CURSOR_CLI_EXECUTABLES } from "../../../shared/providerCliExecutables"; import type { AiLocalProviderConfigs } from "../../../shared/types"; import { inspectLocalProvider, clearLocalProviderInspectionCache } from "./localModelDiscovery"; import { resolveDroidExecutable } from "./droidExecutable"; @@ -82,12 +83,20 @@ const CLI_AUTH_PROBES: Record = { ["status", "--json"], ["status"], ], - droid: [["--version"], ["-V"], ["version"]], + // Documented flags only. `droid --help` on v0.186.0 lists exec/daemon/search/ + // update/mcp/plugin/computer/help and nothing else, so anything that is not a + // real subcommand — `version`, `whoami`, `account status` — is taken as a + // *prompt* and boots the full interactive TUI, burning the spawn timeout. + droid: [["--version"], ["-v"]], }; +function cliSpawnCommands(cli: CliName): readonly string[] { + if (cli === "cursor") return CURSOR_CLI_EXECUTABLES.launchCandidates; + return [cli]; +} + function cliSpawnCommand(cli: CliName): string { - if (cli === "cursor") return "agent"; - return cli; + return cliSpawnCommands(cli)[0]!; } const AUTH_INDICATORS = [ @@ -103,6 +112,10 @@ const AUTH_INDICATORS = [ const STRONG_UNAUTH_INDICATORS = [ /not logged in/i, /not authenticated/i, + // Droid's real refusal, verbatim from v0.186.0: "Error during droid + // execution: Authentication failed. Please log in using /login or set a valid + // FACTORY_API_KEY environment variable." None of the other patterns match it. + /authentication failed/i, /login required/i, /sign in required/i, /unauthorized/i, @@ -140,56 +153,71 @@ function findExplicitCommandPath(command: string): string | null { return resolveExecutableFromKnownLocations(command)?.path ?? null; } -async function commandExists(command: string): Promise { +/** + * Resolve where a CLI actually lives, or null when it is not installed. + * + * One function answers both "is it installed" and "what do we launch", so the + * Settings card can never advertise a provider the chat runtime cannot spawn: + * `installed` is exactly "this file exists" and `path` is exactly that file, + * which is what flows into {@link DetectedAuth} and on into + * `resolveClaudeCodeExecutable`/`resolveDroidExecutable`. + * + * Windows: never probe by exit code. `spawnAsync` routes extension-less + * commands through `cmd.exe /d /s /c "…"` (see `resolveCliSpawnInvocation`), + * and cmd.exe itself always starts — a missing binary comes back as exit 1 + * with `'claude' is not recognized as an internal or external command`, not as + * the ENOENT spawn error (`status === null`) that means "missing" on + * macOS/Linux. An exit-code probe therefore reports *every* CLI as installed + * on Windows. `where.exe` + the known-install-dir scan (which honours PATHEXT) + * answer the question honestly. + */ +async function resolveCommandLocation(command: string): Promise { const explicitPath = findExplicitCommandPath(command); - if (explicitPath) return true; + if (explicitPath) return explicitPath; - // Strategy 1: Direct spawn — bypasses shell init (.zshrc errors, slow profiles). - // If the binary exists, --version will produce *some* exit code. - // A spawn error (ENOENT) means the binary isn't on PATH → status is null. - try { - const direct = await spawnAsync(command, ["--version"], { timeout: 5_000 }); - if (direct.status !== null) return true; - } catch { - // fall through to shell-based check + if (process.platform === "win32") { + try { + // Spell the lookup `where.exe`, not `where`. `spawnAsync` routes an + // *extensionless* command through `cmd.exe /d /s /c "…"`; the extension + // here keeps the probe a direct spawn with no wrapper. Measured: direct + // `where.exe` 57.7ms vs `cmd + where` 73.7ms per lookup, and because the + // wrapper is what blocks the main thread, the worst *unrelated* IPC + // observed during a probe drops from 1364.5ms to 18.2ms. + const result = await spawnAsync("where.exe", [command], { timeout: 5_000 }); + if (result.status === 0) { + const first = (result.stdout ?? "").trim().split(/\r?\n/)[0]?.trim(); + if (first) return first; + } + } catch { + // Treat a failed lookup as "not installed" rather than guessing. + } + return null; } - // Strategy 2: Shell-based lookup (fallback for edge cases) + // POSIX: a direct spawn bypasses shell init (.zshrc errors, slow profiles), + // and here ENOENT really does surface as `status === null`. try { - if (process.platform === "win32") { - const result = await spawnAsync("where", [command], { timeout: 5_000 }); - return result.status === 0; + const direct = await spawnAsync(command, ["--version"], { timeout: 5_000 }); + if (direct.status !== null) { + const which = await spawnAsync("which", [command], { timeout: 3_000 }); + const line = which.status === 0 ? (which.stdout ?? "").trim() : ""; + return line || command; } - const result = await spawnAsync(getLookupShell(), ["-lc", 'command -v "$1" >/dev/null 2>&1', "--", command], { timeout: 5_000 }); - return result.status === 0; } catch { - // fall through to explicit common-path lookup + // fall through to shell-based lookup } - return explicitPath != null; -} - -async function commandPath(command: string): Promise { try { - if (process.platform === "win32") { - const result = await spawnAsync("where", [command], { timeout: 5_000 }); - return result.stdout?.trim().split(/\r?\n/)[0] ?? command; - } - // Try which first (simpler, doesn't load full login shell) - const which = await spawnAsync("which", [command], { timeout: 3_000 }); - if (which.status === 0 && which.stdout?.trim()) { - return which.stdout.trim(); - } - const explicitPath = findExplicitCommandPath(command); - if (explicitPath) { - return explicitPath; - } - // Fallback to login shell lookup const result = await spawnAsync(getLookupShell(), ["-lc", 'command -v "$1"', "--", command], { timeout: 5_000 }); - return result.stdout?.trim() || command; + if (result.status === 0) { + const line = (result.stdout ?? "").trim(); + if (line) return line; + } } catch { - return findExplicitCommandPath(command) ?? command; + // Not installed. } + + return null; } async function refreshProcessPathFromShell(): Promise { @@ -378,6 +406,22 @@ async function inspectCursorCliAuthentication(command: string): Promise<{ return { authenticated: false, verified: false, paidPlan: false }; } +/** + * Best-effort check for a Factory credential ADE can see without launching droid. + * + * KNOWN GAP, do not rediscover from scratch: on v0.186.0 `~/.factory/settings.json` + * holds UI preferences only — the real file on a signed-out machine is + * `{"logoAnimation":"off"}` — and no credential-shaped file exists anywhere under + * `~/.factory` (verified: cache/, certs/, droids/, logs/, sessions/, snapshots/, + * telemetry/, temp/ and four small JSON state files, none of them auth). A stack + * trace in `~/.factory/logs` names a dedicated + * `packages/runtime/auth/src/credentials/CredentialsStorage.ts`, so tokens almost + * certainly live somewhere else in a format we have not seen. Confirming that + * needs a signed-in Factory account, which this machine does not have, so the + * settings.json read stays (harmless, and correct if Factory ever writes there) + * and `false` continues to mean "no credential ADE can see" — never "signed out". + * Callers must not turn a false here into `verified: true`. + */ async function hasDroidConfiguredCredentials(): Promise { if (process.env.FACTORY_API_KEY?.trim()) { return true; @@ -430,42 +474,25 @@ async function inspectDroidCliPresence(command: string, options?: { deep?: boole return { installed: true, authenticated: true, verified: true }; } - try { - const result = await spawnAsync(command, ["exec", "--list-tools"], { timeout: 12_000 }); - const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); - const normalized = combined.toLowerCase(); - if (hasPattern(normalized, STRONG_UNAUTH_INDICATORS)) { - return { installed: true, authenticated: false, verified: true }; - } - if (result.status === 0) { - return { installed: true, authenticated: true, verified: true }; - } - if (hasPattern(normalized, AUTH_INDICATORS)) { - return { installed: true, authenticated: true, verified: true }; - } - } catch { - // Current Droid releases may not support this probe or it may time out; fall back. - } - - const authProbes: string[][] = [ - ["account", "status"], - ["whoami"], - ]; - for (const args of authProbes) { - try { - const result = await spawnAsync(command, args, { timeout: 12_000 }); - const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); - if (hasPattern(combined, STRONG_UNAUTH_INDICATORS)) { - return { installed: true, authenticated: false, verified: true }; - } - if (hasPattern(combined, AUTH_INDICATORS)) { - return { installed: true, authenticated: true, verified: true }; - } - } catch { - // try next probe - } - } - + // Nothing further to ask. Droid v0.186.0 exposes no cheap auth probe, and the + // three this used to run were all wrong: + // + // `droid exec --list-tools` exits 0 with no account at all — it prints the + // local tool policy and never contacts Factory — so it reported a signed-out + // machine as authenticated *and verified*. Measured: exit 0 and a full tool + // listing here, while `droid exec "say hi"` returns "Authentication failed." + // + // `whoami` and `account status` are not subcommands (see CLI_AUTH_PROBES), + // so each booted the interactive TUI and ran until the spawn timeout — a + // forced refresh spawned a 150MB agent twice to learn nothing. + // + // The only authoritative signal is a real `droid exec` round trip, which costs + // a model call on a signed-in machine and cannot be a detection probe. So stop + // at "installed, auth unknown": `verified: false` keeps this out of the + // explicitly-signed-out state, and buildProviderConnections renders it as + // "installed but no credentials were detected", which is exactly true. This + // now agrees with the shallow path — before, forcing a refresh made the answer + // worse, which is the opposite of what a refresh button should do. return { installed: true, authenticated: false, verified: false }; } @@ -1100,9 +1127,17 @@ export async function detectCliAuthStatuses(options?: { force?: boolean; skipAut // Probe all CLIs in parallel const statuses = await Promise.all( cliChecks.map(async (cli) => { - const spawnName = cliSpawnCommand(cli); - const installed = await commandExists(spawnName); - const path = installed ? await commandPath(spawnName) : null; + let spawnName = cliSpawnCommand(cli); + let path: string | null = null; + for (const candidate of cliSpawnCommands(cli)) { + const location = await resolveCommandLocation(candidate); + if (location) { + spawnName = candidate; + path = location; + break; + } + } + const installed = path !== null; const cmd = path ?? spawnName; if (!installed) { return { @@ -1135,29 +1170,15 @@ export async function detectCliAuthStatuses(options?: { force?: boolean; skipAut }; } if (cli === "droid") { - // Prefer the path we already proved via commandPath() above; only fall - // back to resolveDroidExecutable() when commandPath() failed. - let droidPath: string; - if (path) { - droidPath = path; - } else { - const resolved = resolveDroidExecutable({ env: process.env }); - if (resolved.source === "fallback-command") { - return { - cli, - installed: false, - path: null, - authenticated: false, - verified: false, - }; - } - droidPath = resolved.path; - } - const auth = await inspectDroidCliPresence(droidPath, { deep: options?.force === true }); + // `path` is a file resolveCommandLocation() proved exists, so it is + // strictly better than resolveDroidExecutable(), whose last resort is + // the bare command name. Reached only when installed, so the shallow + // presence check below is asking about credentials, not existence. + const auth = await inspectDroidCliPresence(cmd, { deep: options?.force === true }); return { cli, installed: auth.installed, - path: droidPath, + path, authenticated: auth.authenticated, verified: auth.verified, }; diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts index d4788d0ea..33d8cc732 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts @@ -14,6 +14,15 @@ import { const originalPlatform = process.platform; const originalPathDelimiter = path.delimiter; +/** + * Windows cannot execute an extension-less file, so a fixture that stands in + * for an installed CLI has to carry a PATHEXT extension there — the same shape + * `npm i -g` produces (`codex.cmd` next to the `#!/bin/sh` `codex`). + */ +function executableFileName(command: string): string { + return process.platform === "win32" ? `${command}.cmd` : command; +} + function makeExecutable(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n", "utf8"); @@ -55,7 +64,7 @@ describe("cliExecutableResolver", () => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-")); const homeDir = path.join(tempRoot, "home"); const prefixDir = path.join(homeDir, ".npm-global"); - makeExecutable(path.join(prefixDir, "bin", "codex")); + makeExecutable(path.join(prefixDir, "bin", executableFileName("codex"))); fs.mkdirSync(homeDir, { recursive: true }); fs.writeFileSync(path.join(homeDir, ".npmrc"), "prefix=~/.npm-global\n", "utf8"); @@ -79,7 +88,7 @@ describe("cliExecutableResolver", () => { }; expect(resolveExecutableFromKnownLocations("codex", env)).toEqual({ - path: path.join(prefixDir, "bin", "codex"), + path: path.join(prefixDir, "bin", executableFileName("codex")), source: "known-dir", }); }); @@ -99,6 +108,10 @@ describe("cliExecutableResolver", () => { }); it("keeps both Intel and Apple Silicon Homebrew bins on PATH", () => { + // Homebrew is a macOS layout claim, and PATH parsing is delimiter-sensitive, + // so pin the platform instead of inheriting the host's. + setPlatform("darwin"); + setPathDelimiter(":"); const nextPath = augmentPathWithKnownCliDirs("/usr/local/bin:/usr/bin:/bin", { HOME: "/tmp/ade-home", PATH: "/usr/local/bin:/usr/bin:/bin", @@ -115,9 +128,9 @@ describe("cliExecutableResolver", () => { const firstBin = path.join(tempRoot, "first"); const secondBin = path.join(tempRoot, "second"); const knownBin = path.join(homeDir, ".local", "bin"); - makeExecutable(path.join(firstBin, "git")); - makeExecutable(path.join(secondBin, "git")); - makeExecutable(path.join(knownBin, "git")); + makeExecutable(path.join(firstBin, executableFileName("git"))); + makeExecutable(path.join(secondBin, executableFileName("git"))); + makeExecutable(path.join(knownBin, executableFileName("git"))); const realStatSync = fs.statSync; vi.spyOn(fs, "statSync").mockImplementation(((p: fs.PathLike, opts?: any) => { @@ -136,9 +149,9 @@ describe("cliExecutableResolver", () => { }); expect(candidates.slice(0, 3)).toEqual([ - { path: path.join(firstBin, "git"), source: "path" }, - { path: path.join(secondBin, "git"), source: "path" }, - { path: path.join(knownBin, "git"), source: "known-dir" }, + { path: path.join(firstBin, executableFileName("git")), source: "path" }, + { path: path.join(secondBin, executableFileName("git")), source: "path" }, + { path: path.join(knownBin, executableFileName("git")), source: "known-dir" }, ]); }); @@ -234,7 +247,9 @@ describe("cliExecutableResolver", () => { USERPROFILE: userProfile, PATH: "C:\\Windows\\System32", })).toEqual({ - path: path.join(scoopShims, "codex.CMD"), + // statSync is stubbed and the directory does not exist, so the resolver + // cannot read the real on-disk spelling and reports the probed name. + path: path.join(scoopShims, "codex.cmd"), source: "known-dir", }); }); diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts index 248b72b64..c50642835 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -153,9 +153,28 @@ function getWindowsKnownBinDirs(env: NodeJS.ProcessEnv, command: string): string const voltaHome = env.VOLTA_HOME?.trim(); const pnpmHome = env.PNPM_HOME?.trim(); const asdfDataDir = env.ASDF_DATA_DIR?.trim(); + const codexInstallDir = env.CODEX_INSTALL_DIR?.trim(); return uniqueNonEmpty([ + // `npm i -g` writes `.cmd` / `.ps1` shims straight into %APPDATA%\npm. appData ? path.join(appData, "npm") : "", + // Standalone/native installers put per-tool binaries under %LOCALAPPDATA%\Programs + // or %ProgramFiles%, either directly in the tool directory or in its `bin`. + // Claude Code's Windows installer instead uses %USERPROFILE%\.local\bin + // (`claude.exe`), and WinGet publishes shims into the WinGet\Links dir — both + // are listed below. + ...(localAppData + ? [ + path.join(localAppData, "Programs", command), + path.join(localAppData, "Programs", command, "bin"), + ] + : []), + ...(programFiles + ? [ + path.join(programFiles, command), + path.join(programFiles, command, "bin"), + ] + : []), localAppData ? path.join(localAppData, "Programs", "cursor", "resources", "app", "bin") : "", localAppData ? path.join(localAppData, "Programs", "Microsoft VS Code", "bin") : "", localAppData ? path.join(localAppData, "Microsoft", "WinGet", "Links") : "", @@ -186,8 +205,17 @@ function getWindowsKnownBinDirs(env: NodeJS.ProcessEnv, command: string): string pnpmHome || "", asdfDataDir ? path.join(asdfDataDir, "shims") : "", ...readNpmPrefixBinDirs(env), - command === "codex" && programFiles ? path.join(programFiles, "Codex") : "", - command === "codex" && localAppData ? path.join(localAppData, "Programs", "Codex") : "", + // Codex's standalone Windows installer (chatgpt.com/codex/install.ps1) + // unpacks to $CODEX_HOME\packages\standalone\current and exposes the binary + // through %CODEX_INSTALL_DIR%, defaulting to + // %LOCALAPPDATA%\Programs\OpenAI\Codex\bin. It prepends that to the + // *persisted* user PATH, which an already-running ADE never sees — so a PATH + // lookup alone reports a real install as absent. macOS needs no equivalent + // entry: the Unix default is $HOME/.local/bin, already listed above. + command === "codex" ? (codexInstallDir || "") : "", + command === "codex" && localAppData + ? path.join(localAppData, "Programs", "OpenAI", "Codex", "bin") + : "", ]); } @@ -247,27 +275,69 @@ function isExecutableFile(candidatePath: string): boolean { } } +/** Windows launcher extensions, in the order Windows itself would try them. */ +export function windowsExecutableExtensions(env: NodeJS.ProcessEnv = process.env): string[] { + // PATHEXT is conventionally uppercase while the files on disk are lowercase + // (`claude.exe`, `codex.cmd`). Normalize so resolved paths match the real + // filename; NTFS lookups are case-insensitive either way. + const pathext = uniqueNonEmpty((env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")) + .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`).toLowerCase()); + // PATHEXT never lists .PS1 (PowerShell resolves scripts itself), but a + // PowerShell-only shim is still a real, launchable install. Try it last so a + // .exe/.cmd sibling always wins — those run under cmd.exe, .ps1 does not. + if (!pathext.some((ext) => ext.toLowerCase() === ".ps1")) pathext.push(".ps1"); + return pathext; +} + +/** + * NTFS lookups ignore case, so a probe for `codex.cmd` succeeds against a file + * actually named `codex.CMD` and vice versa. The resolved path is surfaced in + * Settings and handed to other tools, so report the name as it is spelled on + * disk instead of however PATHEXT happened to be cased. + */ +function withOnDiskCasing(candidatePath: string): string { + if (process.platform !== "win32") return candidatePath; + const dir = path.dirname(candidatePath); + const base = path.basename(candidatePath); + try { + const actual = fs.readdirSync(dir).find((entry) => entry.toLowerCase() === base.toLowerCase()); + return actual ? path.join(dir, actual) : candidatePath; + } catch { + return candidatePath; + } +} + function resolveFromDirs( command: string, dirs: Iterable, env: NodeJS.ProcessEnv = process.env, ): string | null { - const pathext = process.platform === "win32" - ? uniqueNonEmpty((env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")) - .flatMap((ext) => [ext, ext.toLowerCase(), ext.toUpperCase()]) - : []; const commandHasExtension = path.extname(command).length > 0; + const extensions = process.platform === "win32" && !commandHasExtension + ? windowsExecutableExtensions(env) + : []; for (const dir of dirs) { - const candidatePaths = [path.join(dir, command)]; - if (process.platform === "win32" && !commandHasExtension) { - for (const ext of pathext) { - candidatePaths.push(path.join(dir, `${command}${ext}`)); - } - } + // Windows cannot execute an extension-less file. `npm i -g` drops three + // shims side by side — `codex` (a `#!/bin/sh` script for Git Bash), + // `codex.cmd` and `codex.ps1` — and only the latter two are launchable + // here. Trying `path.join(dir, command)` first therefore handed callers the + // sh script: ADE's own spawns survived it because `resolveCliSpawnInvocation` + // wraps extension-less commands in `cmd.exe`, which re-applies PATHEXT, but + // every consumer that spawns the resolved path directly (the Claude Agent + // SDK via `pathToClaudeCodeExecutable`, node-pty, provider SDKs) gets ENOENT. + // Resolve the way Windows does: PATHEXT only. On other platforms the bare + // name is the executable. + const candidatePaths = extensions.length > 0 + // Uppercase second, for the rare case-sensitive Windows directory. + ? extensions.flatMap((ext) => [ + path.join(dir, `${command}${ext}`), + path.join(dir, `${command}${ext.toUpperCase()}`), + ]) + : [path.join(dir, command)]; for (const candidatePath of candidatePaths) { - if (isExecutableFile(candidatePath)) return candidatePath; + if (isExecutableFile(candidatePath)) return withOnDiskCasing(candidatePath); } } return null; @@ -288,10 +358,8 @@ export function augmentPathWithKnownCliDirs( ): string { return mergePathEntries( pathValue, - getKnownBinDirs("claude", env).join(pathListDelimiter()), - getKnownBinDirs("codex", env).join(pathListDelimiter()), - getKnownBinDirs("agent", env).join(pathListDelimiter()), - getKnownBinDirs("opencode", env).join(pathListDelimiter()), + ...["claude", "codex", "agent", "cursor-agent", "droid", "opencode"].map((command) => + getKnownBinDirs(command, env).join(pathListDelimiter())), ); } @@ -310,6 +378,7 @@ function readShellPath( env, stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, + windowsHide: true, }, ); const startIdx = raw.indexOf(PATH_MARKER_START); diff --git a/apps/desktop/src/main/services/ai/codexExecutable.ts b/apps/desktop/src/main/services/ai/codexExecutable.ts index 6e102e33c..de7e68990 100644 --- a/apps/desktop/src/main/services/ai/codexExecutable.ts +++ b/apps/desktop/src/main/services/ai/codexExecutable.ts @@ -1,5 +1,6 @@ import type { DetectedAuth } from "./authDetector"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver"; @@ -37,20 +38,80 @@ function findCodexAuthPath(auth?: DetectedAuth[]): string | null { return null; } -function pathExists(filePath: string): boolean { +function pathExists(filePath: string, platform: NodeJS.Platform = process.platform): boolean { try { fs.accessSync(filePath, fs.constants.X_OK); return true; } catch { try { fs.accessSync(filePath, fs.constants.F_OK); - return process.platform === "win32"; + // Windows has no execute bit; presence is the only signal available. + return platform === "win32"; } catch { return false; } } } +function homeDirFromEnv(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string | null { + const profile = env.USERPROFILE?.trim(); + const home = env.HOME?.trim(); + if (platform === "win32") return profile || home || os.homedir() || null; + return home || os.homedir() || null; +} + +/** + * Directories used by Codex's own standalone installer + * (`https://chatgpt.com/codex/install.ps1` / `install.sh`). + * + * The installer drops the release under `$CODEX_HOME/packages/standalone/current` + * and exposes it through a "visible bin" directory that it prepends to the + * *persisted* user PATH: + * - Windows: `%CODEX_INSTALL_DIR%` else `%LOCALAPPDATA%\Programs\OpenAI\Codex\bin` + * - macOS/Linux: `$CODEX_INSTALL_DIR` else `$HOME/.local/bin` + * + * A persisted PATH edit is not visible to an already-running login session, so + * ADE must be able to find a standalone install without it. macOS already gets + * this for free because `~/.local/bin` is in the shared known-bin-dir list; the + * Windows equivalent is not, which left standalone Windows installs + * indistinguishable from "Codex is not installed". + */ +function standaloneCodexInstallDirs(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string[] { + const dirs: string[] = []; + const installDir = env.CODEX_INSTALL_DIR?.trim(); + if (installDir) dirs.push(installDir); + + if (platform === "win32") { + const localAppData = env.LOCALAPPDATA?.trim(); + if (localAppData) dirs.push(path.join(localAppData, "Programs", "OpenAI", "Codex", "bin")); + } else { + const home = homeDirFromEnv(env, platform); + if (home) dirs.push(path.join(home, ".local", "bin")); + } + + const configuredHome = env.CODEX_HOME?.trim(); + const home = homeDirFromEnv(env, platform); + const codexHome = configuredHome || (home ? path.join(home, ".codex") : ""); + if (codexHome) { + const current = path.join(codexHome, "packages", "standalone", "current"); + dirs.push(path.join(current, "bin"), current); + } + + return [...new Set(dirs)]; +} + +function findStandaloneCodexExecutable( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): string | null { + const binaryName = platform === "win32" ? "codex.exe" : "codex"; + for (const dir of standaloneCodexInstallDirs(env, platform)) { + const candidate = path.join(dir, binaryName); + if (pathExists(candidate, platform)) return candidate; + } + return null; +} + function listDirectories(rootPath: string): string[] { try { return fs.readdirSync(rootPath, { withFileTypes: true }) @@ -68,7 +129,7 @@ function findVendorCodexBinary(packageRoot: string, platform: NodeJS.Platform): path.join(vendorRoot, "bin", binaryName), path.join(vendorRoot, "codex", binaryName), ]) { - if (pathExists(candidate)) return candidate; + if (pathExists(candidate, platform)) return candidate; } } return null; @@ -158,5 +219,10 @@ export function resolveCodexExecutable(args?: { }; } + const standalone = findStandaloneCodexExecutable(env, args?.platform ?? process.platform); + if (standalone) { + return { path: standalone, source: "common-dir" }; + } + return { path: "codex", source: "fallback-command" }; } diff --git a/apps/desktop/src/main/services/ai/cursorSdkLoader.test.ts b/apps/desktop/src/main/services/ai/cursorSdkLoader.test.ts new file mode 100644 index 000000000..8f7ce4fa8 --- /dev/null +++ b/apps/desktop/src/main/services/ai/cursorSdkLoader.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE, + assertCursorSdkSupportedOnThisPlatform, + isCursorSdkResolutionError, + loadCursorSdk, +} from "./cursorSdkLoader"; + +// Platform/arch are passed explicitly, so these assertions hold on every runner +// and this file is not a platform-gated test. +describe("assertCursorSdkSupportedOnThisPlatform", () => { + it("rejects win32-arm64 with a message naming the missing @cursor/sdk build", () => { + let thrown: unknown; + try { + assertCursorSdkSupportedOnThisPlatform("win32", "arm64"); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toMatch(/win32-arm64/); + expect((thrown as Error).message).toMatch(/@cursor\/sdk/); + expect((thrown as { code?: string }).code).toBe(CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE); + }); + + it("allows win32-x64, darwin and linux on every architecture", () => { + for (const [platform, arch] of [ + ["win32", "x64"], + ["darwin", "arm64"], + ["darwin", "x64"], + ["linux", "arm64"], + ["linux", "x64"], + ] as const) { + expect( + () => assertCursorSdkSupportedOnThisPlatform(platform, arch), + `${platform}-${arch}`, + ).not.toThrow(); + } + }); +}); + +describe("isCursorSdkResolutionError", () => { + it("treats the unsupported-platform failure like an unusable SDK module", () => { + // Callers such as cursorModelsDiscovery drop cached rows and refuse to fall + // back to network discovery when this returns true — which is exactly right + // on a platform where no chat could ever run. + const error = Object.assign(new Error("unsupported"), { + code: CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE, + }); + expect(isCursorSdkResolutionError(error)).toBe(true); + }); + + it("still recognizes genuine module-resolution failures", () => { + expect(isCursorSdkResolutionError( + Object.assign(new Error("nope"), { code: "ERR_MODULE_NOT_FOUND" }), + )).toBe(true); + expect(isCursorSdkResolutionError(new Error("Cannot find package '@cursor/sdk'"))).toBe(true); + expect(isCursorSdkResolutionError(new Error("socket hang up"))).toBe(false); + }); +}); + +describe("loadCursorSdk", () => { + it("fails with the explained blocker on win32-arm64 instead of an opaque import error", async () => { + // The realistic case: settings restored from an x64 machine still name + // Cursor, so something reaches the SDK even though no picker offers it. + const prevPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + const prevArch = Object.getOwnPropertyDescriptor(process, "arch")!; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + Object.defineProperty(process, "arch", { value: "arm64", configurable: true }); + try { + await expect(loadCursorSdk()).rejects.toThrow(/win32-arm64/); + } finally { + Object.defineProperty(process, "platform", prevPlatform); + Object.defineProperty(process, "arch", prevArch); + } + }); +}); diff --git a/apps/desktop/src/main/services/ai/cursorSdkLoader.ts b/apps/desktop/src/main/services/ai/cursorSdkLoader.ts index 9e5c92c2b..99a487523 100644 --- a/apps/desktop/src/main/services/ai/cursorSdkLoader.ts +++ b/apps/desktop/src/main/services/ai/cursorSdkLoader.ts @@ -1,6 +1,10 @@ import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import type * as CursorSdkModuleTypes from "@cursor/sdk"; +import { + CURSOR_WINDOWS_ARM_BLOCKER, + isCursorProviderSupported, +} from "../../../shared/providerPlatformSupport"; export type CursorSdkModule = typeof CursorSdkModuleTypes; @@ -16,11 +20,22 @@ function errorText(error: unknown): string { return String(error); } +/** + * Error code stamped on the win32-arm64 platform-gate failure so callers can + * tell it apart from a genuine runtime error and treat it like an unusable SDK + * module rather than a transient fault. + */ +export const CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE = "ADE_CURSOR_SDK_UNSUPPORTED_PLATFORM"; + export function isCursorSdkResolutionError(error: unknown): boolean { const message = errorText(error); const code = error && typeof error === "object" ? String((error as { code?: unknown }).code ?? "") : ""; + // A platform with no @cursor/sdk build is the same situation as a missing + // module for every caller: the SDK cannot be used, so do not fall back to + // network discovery and advertise models that no chat could ever run. + if (code === CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE) return true; return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || /Cannot find package ['"]@cursor\/sdk['"]/i.test(message) @@ -39,7 +54,25 @@ function loadCursorSdkWithRequire(originalError: unknown): CursorSdkModule { } } +/** + * Backstop for the platform gate. Settings restored from an x64 machine, a + * persisted default model, or a deep link can still name Cursor on a host where + * the provider was filtered out of every picker. Those paths reach the SDK + * directly, so fail here with the same explanation the UI would have given + * rather than an opaque ERR_MODULE_NOT_FOUND from deep inside the import. + */ +export function assertCursorSdkSupportedOnThisPlatform( + platform: string = process.platform, + arch: string = process.arch, +): void { + if (isCursorProviderSupported(platform, arch)) return; + const error = new Error(CURSOR_WINDOWS_ARM_BLOCKER); + (error as { code?: string }).code = CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE; + throw error; +} + export async function loadCursorSdk(): Promise { + assertCursorSdkSupportedOnThisPlatform(); if (sdkModule) return sdkModule; if (!sdkModulePromise) { sdkModulePromise = import("@cursor/sdk") diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts index 3b58a058e..f9e3f21f4 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts @@ -367,4 +367,92 @@ describe("buildProviderConnections", () => { else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; } }); + // Cursor is gated out of Windows on ARM because @cursor/sdk publishes no + // win32-arm64 runtime. Platform/arch are forced here rather than read from the + // host, so these assertions run identically on every CI runner — no platform + // gate, no baseline entry needed. + describe("Cursor on Windows on ARM", () => { + async function withTarget( + platform: string, + arch: string, + run: () => Promise, + ): Promise { + const prevPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + const prevArch = Object.getOwnPropertyDescriptor(process, "arch")!; + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + Object.defineProperty(process, "arch", { value: arch, configurable: true }); + try { + // Must await inside the override: buildProviderConnections reads + // process.arch after its first await point. + return await run(); + } finally { + Object.defineProperty(process, "platform", prevPlatform); + Object.defineProperty(process, "arch", prevArch); + } + } + + it("reports Cursor as hard unavailable on win32-arm64 even with a verified key and a ready runtime", async () => { + const prevKey = process.env.CURSOR_API_KEY; + process.env.CURSOR_API_KEY = "key_live_cursor_agent"; + mockState.getProviderRuntimeHealth.mockImplementation((provider: string) => + provider === "cursor" + ? { state: "ready", message: null, checkedAt: "2026-05-01T12:00:00.000Z" } + : null, + ); + try { + const result = await withTarget("win32", "arm64", () => + buildProviderConnections(mergeCliStatuses([])), + ); + expect(result.cursor.runtimeAvailable).toBe(false); + expect(result.cursor.runtimeDetected).toBe(false); + expect(result.cursor.authAvailable).toBe(false); + expect(result.cursor.usageAvailable).toBe(false); + expect(result.cursor.path).toBeNull(); + expect(result.cursor.sources).toEqual([]); + expect(result.cursor.blocker).toMatch(/win32-arm64/); + } finally { + if (prevKey === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = prevKey; + } + }); + + it("leaves Cursor available on win32-x64 and darwin-arm64 with the same inputs", async () => { + const prevKey = process.env.CURSOR_API_KEY; + process.env.CURSOR_API_KEY = "key_live_cursor_agent"; + mockState.getProviderRuntimeHealth.mockImplementation((provider: string) => + provider === "cursor" + ? { state: "ready", message: null, checkedAt: "2026-05-01T12:00:00.000Z" } + : null, + ); + try { + for (const [platform, arch] of [["win32", "x64"], ["darwin", "arm64"], ["darwin", "x64"]]) { + const result = await withTarget(platform!, arch!, () => + buildProviderConnections(mergeCliStatuses([])), + ); + expect(result.cursor.runtimeAvailable, `${platform}-${arch}`).toBe(true); + expect(result.cursor.runtimeDetected, `${platform}-${arch}`).toBe(true); + expect(result.cursor.path, `${platform}-${arch}`).toBe("@cursor/sdk"); + expect(result.cursor.blocker, `${platform}-${arch}`).toBeNull(); + } + } finally { + if (prevKey === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = prevKey; + } + }); + + it("does not touch Claude, Codex or Droid on win32-arm64", async () => { + const result = await withTarget("win32", "arm64", () => + buildProviderConnections( + mergeCliStatuses([ + { cli: "claude", installed: true, path: "claude", authenticated: true, verified: true }, + { cli: "codex", installed: true, path: "codex", authenticated: true, verified: true }, + { cli: "droid", installed: true, path: "droid", authenticated: true, verified: true }, + ]), + ), + ); + expect(result.claude.runtimeAvailable).toBe(true); + expect(result.codex.runtimeAvailable).toBe(true); + expect(result.droid.runtimeAvailable).toBe(true); + }); + }); }); diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 81a1a18c1..11bfa3264 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -8,6 +8,10 @@ import { import { getAllApiKeys } from "./apiKeyStore"; import { getProviderRuntimeHealth } from "./providerRuntimeHealth"; import { isCursorAdminApiKey } from "./utils"; +import { + CURSOR_WINDOWS_ARM_BLOCKER, + isCursorProviderSupported, +} from "../../../shared/providerPlatformSupport"; import { nowIso } from "../shared/utils"; function createUnavailableStatus( @@ -79,7 +83,12 @@ export async function buildProviderConnections( return `${providerLabel} CLI is installed but no login was detected. Run: ${loginHint}`; } if (!flags.runtimeDetected) { - return `Local credentials exist but ADE could not find the ${providerLabel} CLI. ADE checks the app PATH, login-shell PATH, interactive-shell PATH, and common install directories. If ${providerLabel} is installed elsewhere, add that bin directory to your shell PATH and refresh.`; + // The login-shell/interactive-shell PATH probe is a POSIX-only step — + // `augmentProcessPathWithShellAndKnownCliDirs` skips it on Windows — so + // do not claim it happened, and give the right place to fix PATH. + return process.platform === "win32" + ? `Local credentials exist but ADE could not find the ${providerLabel} CLI. ADE checks the app PATH (honouring PATHEXT) and the common Windows install directories: %APPDATA%\\npm, %USERPROFILE%\\.local\\bin, %LOCALAPPDATA%\\Programs, %LOCALAPPDATA%\\Microsoft\\WinGet\\Links. If ${providerLabel} is installed elsewhere, add that folder to your PATH in System Properties -> Environment Variables, reopen ADE, and refresh.` + : `Local credentials exist but ADE could not find the ${providerLabel} CLI. ADE checks the app PATH, login-shell PATH, interactive-shell PATH, and common install directories. If ${providerLabel} is installed elsewhere, add that bin directory to your shell PATH and refresh.`; } if (extraBlocker) return extraBlocker; return null; @@ -174,6 +183,14 @@ export async function buildProviderConnections( health: codexRuntimeHealth, }); + // Windows on ARM ships no @cursor/sdk runtime, so Cursor is reported as hard + // unavailable before any key or runtime-health work happens. See + // shared/providerPlatformSupport.ts for the reason and the revisit condition. + // This is the authoritative decision point: `availableProviders.cursor`, the + // cursor-family model filter and every settings/onboarding surface downstream + // all derive from the connection built here. + const cursorSupported = isCursorProviderSupported(process.platform, process.arch); + const cursorCli = cliStatuses.find((entry) => entry.cli === "cursor") ?? null; const cursorEnvKey = process.env.CURSOR_API_KEY?.trim() ?? ""; const cursorAdminEnvKey = process.env.CURSOR_ADMIN_API_KEY?.trim() ?? ""; @@ -204,16 +221,18 @@ export async function buildProviderConnections( // ready after verification/model discovery proves the SDK can load and the // key can access agent models. const cursorFlags = { - runtimeDetected: true, + runtimeDetected: cursorSupported, cliAuthenticated: false, cliExplicitlyUnauthenticated: false, - localCredsDetected: cursorAuthAvailable, - authAvailable: cursorAuthAvailable, - runtimeAvailable: cursorRuntimeHealth?.state === "ready", + localCredsDetected: cursorSupported && cursorAuthAvailable, + authAvailable: cursorSupported && cursorAuthAvailable, + runtimeAvailable: cursorSupported && cursorRuntimeHealth?.state === "ready", }; let cursorBlocker: string | null; - if (cursorFlags.runtimeAvailable) { + if (!cursorSupported) { + cursorBlocker = CURSOR_WINDOWS_ARM_BLOCKER; + } else if (cursorFlags.runtimeAvailable) { cursorBlocker = null; } else if (cursorSdkAuth) { cursorBlocker = "Verify the Cursor API key to enable Cursor chat."; @@ -232,25 +251,29 @@ export async function buildProviderConnections( authAvailable: cursorFlags.authAvailable, runtimeDetected: cursorFlags.runtimeDetected, runtimeAvailable: cursorFlags.runtimeAvailable, - usageAvailable: cursorUsageAuth, - path: "@cursor/sdk", - sources: [ - { - kind: "local-credentials", - detected: cursorAuthAvailable, - source: cursorCredsSource, - }, - { - kind: "cli", - detected: Boolean(cursorCli?.installed), - authenticated: cursorCli?.authenticated, - verified: cursorCli?.verified, - path: cursorCli?.path ?? null, - }, - ], + usageAvailable: cursorSupported && cursorUsageAuth, + path: cursorSupported ? "@cursor/sdk" : null, + sources: cursorSupported + ? [ + { + kind: "local-credentials", + detected: cursorAuthAvailable, + source: cursorCredsSource, + }, + { + kind: "cli", + detected: Boolean(cursorCli?.installed), + authenticated: cursorCli?.authenticated, + verified: cursorCli?.verified, + path: cursorCli?.path ?? null, + }, + ] + : [], blocker: cursorBlocker, }; - applyRuntimeHealth(cursor, cursorRuntimeHealth); + // Runtime health can only promote the connection, so it must not run once the + // platform gate has decided Cursor is unavailable. + if (cursorSupported) applyRuntimeHealth(cursor, cursorRuntimeHealth); const droidCli = cliStatuses.find((entry) => entry.cli === "droid") ?? null; const factoryEnvAuth = Boolean(process.env.FACTORY_API_KEY?.trim()); diff --git a/apps/desktop/src/main/services/ai/providerCredentialSources.ts b/apps/desktop/src/main/services/ai/providerCredentialSources.ts index 794613a2a..9da9824ce 100644 --- a/apps/desktop/src/main/services/ai/providerCredentialSources.ts +++ b/apps/desktop/src/main/services/ai/providerCredentialSources.ts @@ -102,6 +102,7 @@ export function runShellCommand( stdio: ["ignore", "pipe", "pipe"], env: process.env, windowsVerbatimArguments: useCmd, + windowsHide: true, }); let stdout = ""; diff --git a/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts index c502e00b1..eea1b696e 100644 --- a/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts @@ -32,6 +32,37 @@ vi.mock("./codexExecutable", () => ({ })); import { makeCodexCompatibleJsonSchema, runProviderTask } from "./providerTaskRunner"; +import { quoteWindowsCmdArg } from "../shared/processExecution"; + +// `runCommand` launches CLIs through `resolveCliSpawnInvocation`. On Windows an +// extensionless/`.cmd`/`.bat` launcher cannot be handed to CreateProcess, so the +// invocation becomes `%ComSpec% /d /s /c ""` and every +// argument is folded into one string. These helpers assert the same argument +// content on both shapes instead of encoding the POSIX shape only. +const isWindowsLaunch = process.platform === "win32"; + +function expectedLaunchCommand(executablePath: string): string { + return isWindowsLaunch ? (process.env.ComSpec?.trim() || "cmd.exe") : executablePath; +} + +function launchArgvContains(argv: unknown, value: string): boolean { + const args = Array.isArray(argv) ? (argv as string[]) : []; + return isWindowsLaunch + ? args.join(" ").includes(quoteWindowsCmdArg(value)) + : args.includes(value); +} + +function launchArgvValueAfter(argv: unknown, flag: string): string | null { + const args = Array.isArray(argv) ? (argv as string[]) : []; + if (!isWindowsLaunch) { + const index = args.indexOf(flag); + return index >= 0 ? (args[index + 1] ?? null) : null; + } + const match = args + .join(" ") + .match(new RegExp(`${quoteWindowsCmdArg(flag).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} "([^"]+)"`)); + return match?.[1] ?? null; +} type MockSpawnProcess = EventEmitter & { stdout: EventEmitter; @@ -143,9 +174,9 @@ describe("runProviderTask", () => { expect(result.text).toBe("READY"); expect(spawnMock).toHaveBeenCalledTimes(1); const [command, argv, options] = spawnMock.mock.calls[0]!; - expect(command).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd"); - expect(argv).toContain("-p"); - expect(argv).not.toContain("Summarize the worktree state."); + expect(command).toBe(expectedLaunchCommand("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd")); + expect(launchArgvContains(argv, "-p")).toBe(true); + expect(launchArgvContains(argv, "Summarize the worktree state.")).toBe(false); expect(options).toMatchObject({ stdio: ["pipe", "pipe", "pipe"], }); @@ -155,8 +186,7 @@ describe("runProviderTask", () => { it("pipes Codex prompts over stdin instead of argv", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-task-runner-")); spawnMock.mockImplementationOnce((_command: unknown, argv: string[]) => { - const outputIndex = argv.indexOf("--output-last-message"); - const outputPath = outputIndex >= 0 ? argv[outputIndex + 1] : null; + const outputPath = launchArgvValueAfter(argv, "--output-last-message"); return createMockProcess({ onStart: () => { if (outputPath) { @@ -187,12 +217,12 @@ describe("runProviderTask", () => { expect(result.text).toBe("DONE"); expect(spawnMock).toHaveBeenCalledTimes(1); const [command, argv, options] = spawnMock.mock.calls[0]!; - expect(command).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd"); - expect(argv).toContain("exec"); - expect(argv).toContain("-"); - expect(argv).toContain("--image"); - expect(argv).toContain("/tmp/settings.png"); - expect(argv).not.toContain("Fix the Windows launcher."); + expect(command).toBe(expectedLaunchCommand("C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd")); + expect(launchArgvContains(argv, "exec")).toBe(true); + expect(launchArgvContains(argv, "-")).toBe(true); + expect(launchArgvContains(argv, "--image")).toBe(true); + expect(launchArgvContains(argv, "/tmp/settings.png")).toBe(true); + expect(launchArgvContains(argv, "Fix the Windows launcher.")).toBe(false); expect(options).toMatchObject({ stdio: ["pipe", "pipe", "pipe"], }); diff --git a/apps/desktop/src/main/services/ai/providerTaskRunner.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.ts index 7ab5abb73..eade02652 100644 --- a/apps/desktop/src/main/services/ai/providerTaskRunner.ts +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.ts @@ -176,6 +176,7 @@ async function runCommand(args: { env, stdio: [args.stdinText != null ? "pipe" : "ignore", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); let stdout = ""; diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 9064a7a7e..416d69c88 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -2075,6 +2075,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record/dev/null 2>&1`], { encoding: "utf8" }); + const result = spawnSync("sh", ["-lc", `command -v ${command} >/dev/null 2>&1`], { + encoding: "utf8", + windowsHide: true, + }); return result.status === 0; } catch { return false; diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts index 5f5579a38..fafdc7d54 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts @@ -6,13 +6,136 @@ import { commandForwardsAppControlDebug, commandLooksLikeDirectElectronLaunch, commandLooksLikePackageScriptLaunch, + gitBashPath, insertDebugFlagsIntoDirectElectronCommand, + resolveDirectElectronLaunch, + resolvePackageScriptElectronLaunch, rewritePackageScriptElectronLaunch, + shellQuote, } from "./appControlLaunchCommand"; const DEBUG_FLAGS = ["--remote-debugging-port=9222"]; describe("appControlLaunchCommand", () => { + it("resolves direct Windows Electron commands into argv and env without shell interpolation", () => { + const value = "C:\\Program Files\\ADE's $lane %TEMP% & café"; + expect(resolveDirectElectronLaunch( + `ADE_TEST="${value}" npx electron "C:\\Program Files\\My & App café"`, + DEBUG_FLAGS, + { platform: "win32" }, + )).toEqual({ + command: "npx", + args: ["electron", ...DEBUG_FLAGS, "C:\\Program Files\\My & App café"], + env: { ADE_TEST: value }, + commandForDisplay: expect.any(String), + }); + }); + + it("falls back to the configured shell for single-quoted Windows argv", () => { + expect(resolveDirectElectronLaunch( + "electron 'C:\\Program Files\\My App\\main.js'", + DEBUG_FLAGS, + { platform: "win32" }, + )).toBeNull(); + }); + + it("resolves package scripts into a direct local Electron invocation on Windows", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade app control $ % & café-")); + try { + fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({ + scripts: { + dev: "ADE_TEST=\"quoted $value %TEMP% & café\" electron \"app folder\"", + }, + }), "utf8"); + + const resolved = resolvePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32" }, + ); + expect(resolved).toEqual({ + command: path.join(projectRoot, "node_modules", ".bin", "electron.cmd"), + args: [...DEBUG_FLAGS, "app folder"], + cwd: projectRoot, + env: { ADE_TEST: "quoted $value %TEMP% & café" }, + commandForDisplay: expect.any(String), + }); + expect(resolved?.commandForDisplay).not.toContain("PATH="); + expect(resolved?.commandForDisplay).not.toContain("$PATH"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("emits native PowerShell and cmd environment syntax for complex Windows script fallbacks", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade app control $ % & café-")); + try { + fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({ + scripts: { + dev: "electron . && node post-launch.js", + }, + }), "utf8"); + + const powershell = rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32", shell: "powershell" }, + ); + expect(powershell).toContain("Set-Location -LiteralPath '"); + expect(powershell).toContain("$env:PATH = '"); + expect(powershell).toContain("' + $env:PATH;"); + expect(powershell).not.toContain(":$PATH"); + expect(powershell).not.toContain(" && "); + + const cmd = rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32", shell: "cmd" }, + ); + expect(cmd).toContain('cd /d "'); + expect(cmd).toContain('set "PATH='); + expect(cmd).toContain(';%PATH%" &&'); + expect(cmd).not.toContain(":$PATH"); + + const gitBash = rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32", shell: "git-bash" }, + ); + // The fixture's package directory is a host-native temp dir: a drive-letter + // path on Windows, a plain POSIX path on a Linux runner. Deriving the + // expected `cd` target from the fixture keeps this assertion exact on every + // host; the drive-letter -> MSYS rewrite itself is pinned host-independently + // by the `gitBashPath` case below. + const expectedCd = `cd -- ${shellQuote(gitBashPath(projectRoot))} && `; + expect(gitBash?.slice(0, expectedCd.length)).toBe(expectedCd); + expect(gitBash).not.toContain(String.fromCharCode(92)); + expect(gitBash).toContain("/node_modules/.bin'"); + expect(gitBash).toContain(":$PATH"); + expect(gitBash).toContain(" && "); + expect(gitBash).not.toContain("Set-Location"); + expect(gitBash).not.toContain('set "PATH='); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("rewrites Windows drive-letter paths into MSYS form for Git Bash", () => { + // Git Bash receives MSYS paths, not native Windows ones. This runs on every + // host because the inputs are literals rather than host-native temp dirs, so + // the conversion stays covered on the Linux unit runner as well as Windows. + expect(gitBashPath("C:\\Users\\ade\\my app")).toBe("/c/Users/ade/my app"); + expect(gitBashPath("D:/work/app/node_modules/.bin")).toBe("/d/work/app/node_modules/.bin"); + expect(gitBashPath("c:\\ade")).toBe("/c/ade"); + // Rootless and UNC paths have no drive letter to fold; MSYS takes them as-is. + expect(gitBashPath("/tmp/ade-app-control")).toBe("/tmp/ade-app-control"); + expect(gitBashPath("\\\\server\\share\\app")).toBe("//server/share/app"); + }); + it("detects direct Electron launches and injects debug flags after electron", () => { expect(commandLooksLikeDirectElectronLaunch("FOO=bar npx electron .")).toBe(true); @@ -30,8 +153,13 @@ describe("appControlLaunchCommand", () => { }), "utf8"); expect(commandLooksLikePackageScriptLaunch("npm run dev")).toBe(true); - expect(rewritePackageScriptElectronLaunch("npm run dev", DEBUG_FLAGS, projectRoot)) - .toBe(`PATH=${path.join(projectRoot, "node_modules", ".bin")}:$PATH electron --remote-debugging-port=9222 .`); + expect(rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "linux" }, + )) + .toBe(`PATH=${shellQuote(path.join(projectRoot, "node_modules", ".bin"))}:$PATH electron --remote-debugging-port=9222 .`); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } @@ -64,8 +192,13 @@ describe("appControlLaunchCommand", () => { }, }), "utf8"); - expect(rewritePackageScriptElectronLaunch("cd apps/desktop && npm run dev", DEBUG_FLAGS, projectRoot)) - .toBe(`cd apps/desktop && PATH=${path.join(appDir, "node_modules", ".bin")}:$PATH electron --remote-debugging-port=9222 .`); + expect(rewritePackageScriptElectronLaunch( + "cd apps/desktop && npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "linux" }, + )) + .toBe(`cd apps/desktop && PATH=${shellQuote(path.join(appDir, "node_modules", ".bin"))}:$PATH electron --remote-debugging-port=9222 .`); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts index efcd667ab..559b71c7d 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts @@ -1,5 +1,23 @@ import fs from "node:fs"; import path from "node:path"; +import { commandArrayToLine, parseCommandLine } from "../../../shared/shell"; +import type { WindowsShellKind } from "../../../shared/types"; + +export type AppControlDirectLaunch = { + command: string; + args: string[]; + commandForDisplay: string; + env?: Record; +}; + +export type AppControlPackageLaunch = AppControlDirectLaunch & { + cwd: string; +}; + +type LaunchOptions = { + platform?: NodeJS.Platform; + shell?: WindowsShellKind; +}; export function shellQuote(value: string): string { if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value; @@ -37,6 +55,120 @@ export function insertDebugFlagsIntoDirectElectronCommand(command: string, debug ); } +function takeLeadingEnv(input: string): { env: Record; rest: string } { + const env: Record = {}; + let rest = input.trim(); + const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s;&|]+))(?:\s+|$)/; + while (rest) { + const match = rest.match(assignment); + if (!match) break; + env[match[1]!] = match[2] ?? match[3] ?? match[4] ?? ""; + rest = rest.slice(match[0].length).trimStart(); + } + return { env, rest }; +} + +export function resolveDirectElectronLaunch( + command: string, + debugFlags: string[], + options: LaunchOptions = {}, +): AppControlDirectLaunch | null { + const platform = options.platform ?? process.platform; + const { env, rest } = takeLeadingEnv(command); + // Windows' CRT argv rules do not recognize PowerShell-style single quotes. + // Accepting them here would silently split paths containing spaces; leave + // those commands to the selected shell, which owns their quoting semantics. + if (platform === "win32" && rest.includes("'")) return null; + let argv: string[]; + try { + argv = parseCommandLine(rest, { platform }); + } catch { + return null; + } + if (argv.some((arg) => arg === "&&" || arg === "||" || arg === ";" || arg === "|")) { + return null; + } + + const usesNpx = argv[0]?.toLowerCase() === "npx" && argv[1]?.toLowerCase() === "electron"; + const directElectron = argv[0]?.toLowerCase() === "electron" || argv[0]?.toLowerCase() === "electron.exe"; + if (!usesNpx && !directElectron) return null; + + const executable = argv[0]!; + const prefixArgs = usesNpx ? [argv[1]!] : []; + const appArgs = argv.slice(usesNpx ? 2 : 1); + const args = [...prefixArgs, ...debugFlags, ...appArgs]; + return { + command: executable, + args, + commandForDisplay: commandArrayToLine([executable, ...args], { platform }), + ...(Object.keys(env).length ? { env } : {}), + }; +} + +function packageScriptMatch(command: string): RegExpMatchArray | null { + return command.trim().match( + /^(?.*?)(?(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s+)*)(?npm|pnpm|yarn|bun)\s+(?:run\s+)?(?