From 4aa026272e8e6b45c53ea5605be8b0c5894bc72b Mon Sep 17 00:00:00 2001 From: David Whatley Date: Sat, 1 Aug 2026 19:10:13 -0400 Subject: [PATCH 01/12] feat(windows): port packaging and update support from #999 Based-on: nsxdavid/ADE#999 --- .github/workflows/ci.yml | 50 ++++ .github/workflows/prepare-release.yml | 23 +- .github/workflows/release-core.yml | 250 +++++++++++------- apps/ade-cli/src/commands/brainUpdate.ts | 2 + apps/desktop/build/installer.nsh | 17 ++ apps/desktop/package.json | 34 ++- apps/desktop/scripts/ade-cli-install-path.cmd | 7 +- .../scripts/after-pack-runtime-fixes.cjs | 5 +- apps/desktop/scripts/run-electron-builder.mjs | 65 +++++ .../scripts/run-windows-test-build.mjs | 33 +++ .../scripts/validate-runtime-resources.mjs | 8 +- .../scripts/validate-whisper-resources.mjs | 6 + .../scripts/validate-win-artifacts.mjs | 213 ++++++++++++--- apps/desktop/scripts/windows-authenticode.mjs | 34 +++ .../scripts/windows-authenticode.test.mjs | 30 +++ .../scripts/windows-release-contract.test.mjs | 242 +++++++++++++++++ .../scripts/windows-uninstall-cleanup.ps1 | 243 +++++++++++++++++ .../windows-uninstall-cleanup.test.mjs | 203 ++++++++++++++ .../src/main/packagedRuntimeSmoke.test.ts | 9 + apps/desktop/src/main/packagedRuntimeSmoke.ts | 28 ++ .../src/main/packagedRuntimeSmokeShared.ts | 36 +++ .../updates/autoUpdateService.test.ts | 59 +++-- .../services/updates/autoUpdateService.ts | 42 ++- .../services/updates/autoUpdateVersions.ts | 19 +- apps/web/src/app/pages/DownloadPage.tsx | 34 ++- apps/web/src/lib/marketingAnalytics.test.ts | 21 ++ apps/web/src/lib/marketingAnalytics.ts | 2 + 27 files changed, 1529 insertions(+), 186 deletions(-) create mode 100644 apps/desktop/build/installer.nsh create mode 100644 apps/desktop/scripts/run-electron-builder.mjs create mode 100644 apps/desktop/scripts/run-windows-test-build.mjs create mode 100644 apps/desktop/scripts/windows-authenticode.mjs create mode 100644 apps/desktop/scripts/windows-authenticode.test.mjs create mode 100644 apps/desktop/scripts/windows-release-contract.test.mjs create mode 100644 apps/desktop/scripts/windows-uninstall-cleanup.ps1 create mode 100644 apps/desktop/scripts/windows-uninstall-cleanup.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36d85d70e..436a4afc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -524,6 +524,55 @@ 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: 45 + 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 and smoke unsigned 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: 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 @@ -574,6 +623,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..bec56f9da 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -7,6 +7,10 @@ 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 permissions: actions: read @@ -22,14 +26,17 @@ jobs: steps: - 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 +53,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 diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index d83b322e1..243fd017a 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -20,7 +20,7 @@ on: permissions: actions: read checks: read - contents: write + contents: read jobs: verify: @@ -68,6 +68,16 @@ jobs: echo "ci-pass succeeded for $TARGET_REF: $url" + - name: Validate Windows release configuration + env: + BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} + PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} + run: | + if [ "$PUBLISH_WINDOWS" = "1" ] && [ "$BUILD_WINDOWS" != "1" ]; then + echo "::error::ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1." + exit 1 + fi + build-mac-release: needs: - verify @@ -242,78 +252,94 @@ 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: + if: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' }} + 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: Require Windows Authenticode signing secrets + shell: pwsh + env: + CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD }} + ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} + run: | + if ([string]::IsNullOrWhiteSpace($env:CSC_LINK) -or [string]::IsNullOrWhiteSpace($env:CSC_KEY_PASSWORD)) { + throw "Public Windows releases require WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD (or the WIN_* aliases)." + } + if ([string]::IsNullOrWhiteSpace($env:ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT) -and [string]::IsNullOrWhiteSpace($env:ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT)) { + throw "Public Windows releases require WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT." + } + - 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 }} + CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD }} + ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} + run: cd apps/desktop && npm run dist:win:signed + + - 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-runtime-binaries: needs: verify @@ -450,10 +476,24 @@ jobs: compression-level: 0 publish-release: - if: ${{ inputs.publish }} + if: >- + ${{ + always() + && inputs.publish + && needs.build-runtime-binaries.result == 'success' + && needs.build-mac-release.result == 'success' + && ( + vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1' + || needs.build-win-release.result == 'success' + ) + }} needs: - build-runtime-binaries - build-mac-release + - build-win-release + permissions: + actions: read + contents: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -486,15 +526,12 @@ jobs: 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 Windows release artifacts + if: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && 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 @@ -544,10 +581,6 @@ jobs: 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." @@ -567,37 +600,64 @@ jobs: } done + - name: Validate gated Windows publish asset manifest + if: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} + run: | + set -euo pipefail + shopt -s nullglob + installers=(release-assets/win/*.exe) + blockmaps=(release-assets/win/*.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 - 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 }} + BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} + PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} 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. + # The per-arch macOS zips + latest-mac.yml are what electron-updater + # consumes; DMGs are the human downloads. Mac blockmaps stay omitted. + # When the post-upgrade proof gate is enabled, Windows adds its signed + # installer, blockmap, and latest.yml as one validated draft-release set. 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-* ) + # This repository variable stays disabled until the signed installer + # passes the clean-host release checks. + if [ "$BUILD_WINDOWS" = "1" ] && [ "$PUBLISH_WINDOWS" = "1" ]; then + files+=( + release-assets/win/*.exe + release-assets/win/*.exe.blockmap + release-assets/win/latest.yml + ) + fi + 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 gh release upload "$TAG_NAME" "${files[@]}" --repo "$GH_REPO" --clobber else gh release create "$TAG_NAME" "${files[@]}" \ diff --git a/apps/ade-cli/src/commands/brainUpdate.ts b/apps/ade-cli/src/commands/brainUpdate.ts index 85308f794..f2b718c05 100644 --- a/apps/ade-cli/src/commands/brainUpdate.ts +++ b/apps/ade-cli/src/commands/brainUpdate.ts @@ -364,6 +364,7 @@ function runCommand( cwd: options.cwd, env: options.env, encoding: "utf8", + windowsHide: true, }); return { status: result.status, @@ -689,6 +690,7 @@ function spawnDetached(command: string, args: string[], options: SpawnOptions): ...options, detached: true, stdio: "ignore", + windowsHide: true, }); child.unref(); } diff --git a/apps/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh new file mode 100644 index 000000000..d777b1e8f --- /dev/null +++ b/apps/desktop/build/installer.nsh @@ -0,0 +1,17 @@ +!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} + 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..14cdfa7b6 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,7 @@ "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", + "test:win:release-contract": "node --test ./scripts/windows-release-contract.test.mjs ./scripts/windows-authenticode.test.mjs ./scripts/windows-uninstall-cleanup.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", @@ -213,6 +219,7 @@ "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/**" ], @@ -298,10 +305,6 @@ "!ggml-base.en.bin" ] }, - { - "from": "resources/app-update.yml", - "to": "app-update.yml" - }, { "from": "../../NOTICE", "to": "NOTICE" @@ -332,7 +335,26 @@ ], "rfc3161TimeStampServer": "http://timestamp.digicert.com" }, - "artifactName": "${productName}-${version}-win-${arch}.${ext}" + "artifactName": "${productName}-${version}-win-${arch}.${ext}", + "extraResources": [ + { + "from": "scripts/windows-uninstall-cleanup.ps1", + "to": "ade-cli/windows-uninstall-cleanup.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" }, "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/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs index 4214abed4..94debff58 100644 --- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -128,10 +128,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}`); } } @@ -422,6 +422,7 @@ module.exports = async function afterPack(context) { } 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"); } 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/run-electron-builder.mjs b/apps/desktop/scripts/run-electron-builder.mjs new file mode 100644 index 000000000..c1dfe6159 --- /dev/null +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +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); + +if (!repositoryMatch) { + throw new Error( + `ADE_RELEASE_REPOSITORY must be a GitHub owner/repo pair, received: ${configuredRepository || "empty"}`, + ); +} + +if (requireSigning) { + const missingSecrets = ["CSC_LINK", "CSC_KEY_PASSWORD"].filter((name) => !process.env[name]?.trim()); + if (missingSecrets.length > 0) { + throw new Error( + `Signed Windows packaging requires ${missingSecrets.join(" and ")}. ` + + "Unsigned artifacts are allowed only through npm run dist:win.", + ); + } +} + +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}`, + ...(requireSigning ? ["--config.forceCodeSigning=true"] : []), +]; + +console.log( + `[windows-package] Building for ${owner}/${repo}${requireSigning ? " with required Authenticode signing" : " (unsigned allowed)"}.`, +); +const child = spawn(electronBuilderBin, args, { + cwd: desktopRoot, + env: process.env, + stdio: "inherit", + shell: process.platform === "win32", + 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..890bf3dd5 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -7,6 +7,7 @@ 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"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const desktopRoot = path.resolve(__dirname, ".."); @@ -20,12 +21,15 @@ const productName = pkg.build?.productName ?? pkg.productName ?? "ADE"; 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 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 +77,25 @@ function shouldRequireSignedArtifacts() { return hasFlag("--require-signed") || process.env.ADE_REQUIRE_WIN_SIGNING === "1"; } +function normalizeCertificateThumbprint(value) { + return value?.replace(/\s+/g, "").toUpperCase() ?? ""; +} + +function expectedWindowsSigningIdentity() { + if (!shouldRequireSignedArtifacts()) return null; + const subject = process.env.ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT?.trim() ?? ""; + const thumbprint = normalizeCertificateThumbprint( + process.env.ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT, + ); + if (!subject && !thumbprint) { + fail( + "Signed Windows validation requires ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT " + + "or ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT so the release cannot be signed by an unexpected publisher.", + ); + } + return { subject, thumbprint }; +} + function resolveAbsolute(input) { if (!input) return null; return path.isAbsolute(input) ? input : path.resolve(desktopRoot, input); @@ -210,10 +233,31 @@ 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-uninstall-cleanup.ps1", "Windows uninstall cleanup script"); + requireFile("build/installer.nsh", "Windows NSIS customization"); requireFile("vendor/crsqlite/win32-x64/crsqlite.dll", "Windows cr-sqlite extension"); assertRequiredBundledAdeCliFiles(resolveBundledAdeCliFiles({ allowMissingSources: true })); @@ -238,13 +282,28 @@ 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 suffix of ["", ".native.tar.gz"]) { + const fileName = `ade-${target}${suffix}`; + 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."); } @@ -476,16 +535,19 @@ 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. + // 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. 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-ai", "bin", "opencode.exe"), "duplicate OpenCode Windows executable"); await assertPathMissing(path.join(unpackedPath, "node_modules", "opencode-windows-x64-baseline"), "baseline OpenCode Windows x64 payload in Windows package"); 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"); @@ -498,6 +560,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 +583,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 +597,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 +615,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 +656,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 +784,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,6 +810,32 @@ 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 ( + expectedIdentity.subject + && identity.subject.toLocaleLowerCase("en-US") !== expectedIdentity.subject.toLocaleLowerCase("en-US") + ) { + fail( + `${description} was signed by an unexpected publisher. ` + + `Expected "${expectedIdentity.subject}", received "${identity.subject}".`, + ); + } + if (expectedIdentity.thumbprint && identity.thumbprint !== expectedIdentity.thumbprint) { + fail( + `${description} was signed by an unexpected certificate thumbprint. ` + + `Expected ${expectedIdentity.thumbprint}, received ${identity.thumbprint}.`, + ); + } + return identity; } async function validateReleaseArtifacts() { @@ -722,8 +854,27 @@ async function validateReleaseArtifacts() { 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, + ); + 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-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs new file mode 100644 index 000000000..a41ded21a --- /dev/null +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -0,0 +1,242 @@ +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"; + +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 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 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"]; + +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) { + assert.ok(runtimeFilter.includes(`ade-${target}`), 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_SIGNED_BUILD_ENABLED == '1'/); + assert.match(windowsRelease, /npm run dist:win:signed/); + assert.match(windowsRelease, /ADE_RELEASE_REPOSITORY:\s*\$\{\{ github\.repository \}\}/); + assert.match(windowsRelease, /WINDOWS_CSC_LINK/); + assert.match(windowsRelease, /WINDOWS_SIGNING_EXPECTED_SUBJECT/); + assert.match(windowsRelease, /WINDOWS_SIGNING_EXPECTED_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.CSC_LINK; + delete env.CSC_KEY_PASSWORD; + 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 CSC_LINK and CSC_KEY_PASSWORD/); +}); + +test("Windows release assets are validated and published as one release set", () => { + const publish = jobBlock(releaseWorkflow, "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/); + assert.match(publish, /- build-win-release/); + assert.match(publish, /permissions:\s*\n\s+actions: read\s*\n\s+contents: write/); + assert.match(publish, /name: ade-win-release-/); + assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'[\s\S]*needs\.build-win-release\.result == 'success'/); + assert.match(verify, /ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1/); + assert.match(publish, /ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1'/); + assert.match(publish, /BUILD_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED \}\}/); + assert.match(publish, /PUBLISH_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \}\}/); + assert.match(publish, /if \[ "\$BUILD_WINDOWS" = "1" \] && \[ "\$PUBLISH_WINDOWS" = "1" \]; then/); + assert.match(publish, /release-assets\/win\/\*\.exe/); + assert.match(publish, /release-assets\/win\/\*\.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 uninstall removes ADE-owned machine integration", () => { + assert.equal(pkg.build.nsis.include, "build/installer.nsh"); + 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", + ); + 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, /ADE_HOME = Join-Path/); + assert.match(cleanup, /app\.asar\.unpacked\\node_modules/); + assert.match(cleanup, /NODE_PATH = \$nodePathEntries/); + assert.match(cleanup, /SetEnvironmentVariable\("Path"/); +}); + +test("release preflight validates the exact approved commit", () => { + 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/); +}); + +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.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, /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..1a71e1999 --- /dev/null +++ b/apps/desktop/scripts/windows-uninstall-cleanup.ps1 @@ -0,0 +1,243 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$InstallDir, + [string]$AppExecutableName = "", + [string]$PackageChannel = "stable", + [string]$CliBinDir = "", + [switch]$SkipServiceRemoval, + [switch]$SkipUserPathUpdate +) + +$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 +) { + $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 + if (-not [string]::Equals( + [System.IO.Path]::GetFileName($targetPath), + "ade.cmd", + [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." + } +} + +$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." + } + + $appExe = Join-Path $resolvedInstallDir $normalizedAppExecutableName + $cliPath = Join-Path $resolvedInstallDir "resources\ade-cli\cli.cjs" + if (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) { + throw "Cannot remove the ADE background service because $normalizedAppExecutableName is missing from $resolvedInstallDir." + } + if (-not (Test-Path -LiteralPath $cliPath -PathType Leaf)) { + throw "Cannot remove the ADE background service because the packaged CLI is missing from $resolvedInstallDir." + } + + $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 + $homeName = if ($normalizedPackageChannel -eq "stable") { ".ade" } else { ".ade-$normalizedPackageChannel" } + $env:ADE_HOME = Join-Path ([System.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($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 + } +} + +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") +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) { + 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 + } +} 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..425b27cea --- /dev/null +++ b/apps/desktop/scripts/windows-uninstall-cleanup.test.mjs @@ -0,0 +1,203 @@ +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"); + +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 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 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"); + fs.mkdirSync(cliRoot, { recursive: true }); + fs.mkdirSync(cliBinDir, { recursive: true }); + + 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"); +}); + +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/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/updates/autoUpdateService.test.ts b/apps/desktop/src/main/services/updates/autoUpdateService.test.ts index 24c6b8557..fb7481d4f 100644 --- a/apps/desktop/src/main/services/updates/autoUpdateService.test.ts +++ b/apps/desktop/src/main/services/updates/autoUpdateService.test.ts @@ -72,9 +72,12 @@ describe("buildReleaseNotesUrl", () => { }); describe("buildGithubReleaseUrl", () => { - it("points at the GitHub release tag and normalizes the version", () => { + it("points at the upstream GitHub release tag by default and supports an override", () => { expect(buildGithubReleaseUrl("1.2.18")).toBe("https://github.com/arul28/ADE/releases/tag/v1.2.18"); expect(buildGithubReleaseUrl("v1.2.18")).toBe("https://github.com/arul28/ADE/releases/tag/v1.2.18"); + expect(buildGithubReleaseUrl("1.2.18", "acme/custom-ade")).toBe( + "https://github.com/acme/custom-ade/releases/tag/v1.2.18", + ); expect(buildGithubReleaseUrl(" ")).toBeNull(); }); }); @@ -169,7 +172,7 @@ describe("createAutoUpdateService", () => { service.dispose(); }); - it("configures the GitHub update feed explicitly", () => { + it("uses electron-builder app-update.yml as the packaged feed authority", () => { const updater = new FakeAutoUpdater(); const service = createAutoUpdateService({ logger: makeLogger(), @@ -181,16 +184,12 @@ describe("createAutoUpdateService", () => { updater, }); - expect(updater.setFeedURL).toHaveBeenCalledWith({ - provider: "github", - owner: "arul28", - repo: "ADE", - }); + expect(updater.setFeedURL).not.toHaveBeenCalled(); service.dispose(); }); - it("ignores ADE_UPDATE_FEED_URL in packaged builds and uses the GitHub feed", () => { + it("ignores ADE_UPDATE_FEED_URL in packaged builds without replacing app-update.yml", () => { electronAppMock.isPackaged = true; process.env.ADE_UPDATE_FEED_URL = "https://attacker.example.com/feed"; const updater = new FakeAutoUpdater(); @@ -204,14 +203,7 @@ describe("createAutoUpdateService", () => { updater, }); - expect(updater.setFeedURL).toHaveBeenCalledWith({ - provider: "github", - owner: "arul28", - repo: "ADE", - }); - expect(updater.setFeedURL).not.toHaveBeenCalledWith( - expect.objectContaining({ provider: "generic" }), - ); + expect(updater.setFeedURL).not.toHaveBeenCalled(); service.dispose(); }); @@ -286,6 +278,41 @@ describe("createAutoUpdateService", () => { service.dispose(); }); + it("uses the packaged update repository for post-install GitHub links", () => { + const globalStatePath = makeStatePath(); + fs.writeFileSync(globalStatePath, JSON.stringify({ + recentlyInstalledUpdate: { + version: "1.2.3", + installedAt: "2026-04-06T15:20:00.000Z", + releaseNotesUrl: "https://www.ade-app.dev/docs/changelog/v1.2.3", + githubReleaseUrl: "https://github.com/arul28/ADE/releases/tag/v1.2.3", + }, + }), "utf8"); + + const service = createAutoUpdateService({ + logger: makeLogger(), + currentVersion: "1.2.3", + globalStatePath, + releaseRepository: "acme/custom-ade", + startupDelayMs: 60_000, + periodicCheckMs: 60_000, + now: () => "2026-04-06T15:21:00.000Z", + updater: new FakeAutoUpdater(), + }); + + expect(service.getSnapshot().recentlyInstalled?.githubReleaseUrl).toBe( + "https://github.com/acme/custom-ade/releases/tag/v1.2.3", + ); + const persisted = readState(globalStatePath) as { + recentlyInstalledUpdate?: { githubReleaseUrl?: string | null }; + }; + expect(persisted.recentlyInstalledUpdate?.githubReleaseUrl).toBe( + "https://github.com/acme/custom-ade/releases/tag/v1.2.3", + ); + + service.dispose(); + }); + // The archive is checksum-verified before the update is offered, so one // failed handoff does not make it suspect. Re-downloading the whole release // on every retry is what made a flaky install cost gigabytes. diff --git a/apps/desktop/src/main/services/updates/autoUpdateService.ts b/apps/desktop/src/main/services/updates/autoUpdateService.ts index 39e5128f8..904c4ad12 100644 --- a/apps/desktop/src/main/services/updates/autoUpdateService.ts +++ b/apps/desktop/src/main/services/updates/autoUpdateService.ts @@ -28,6 +28,7 @@ import { buildReleaseNotesUrl, compareUpdateVersions, DEFAULT_RELEASE_NOTES_BASE_URL, + DEFAULT_RELEASE_REPOSITORY, } from "./autoUpdateVersions"; const DEFAULT_INSTALL_WATCHDOG_MS = 30_000; @@ -108,6 +109,7 @@ type CreateAutoUpdateServiceArgs = { now?: () => string; nowMs?: () => number; releaseNotesBaseUrl?: string; + releaseRepository?: string; startupDelayMs?: number; periodicCheckMs?: number; updaterCacheDir?: string; @@ -200,14 +202,16 @@ function cloneRecentlyInstalledUpdate( return update ? { ...update } : null; } -// Backfills the GitHub release URL for updates persisted before that field -// existed, so the renderer always has a "View on GitHub" target. +// Backfill missing links and replace stale links from packages that did not +// persist their configured update repository yet. function withGithubReleaseUrl( update: RecentlyInstalledUpdate | null, + releaseRepository: string, ): RecentlyInstalledUpdate | null { if (!update) return null; - if (update.githubReleaseUrl) return update; - return { ...update, githubReleaseUrl: buildGithubReleaseUrl(update.version) }; + const githubReleaseUrl = buildGithubReleaseUrl(update.version, releaseRepository); + if (update.githubReleaseUrl === githubReleaseUrl) return update; + return { ...update, githubReleaseUrl }; } function cloneSnapshot(snapshot: AutoUpdateSnapshot): AutoUpdateSnapshot { @@ -266,6 +270,7 @@ function reconcilePersistedUpdateState(args: { currentVersion: string; now: string; releaseNotesBaseUrl: string; + releaseRepository: string; }): { state: GlobalState; changed: boolean; @@ -309,7 +314,7 @@ function reconcilePersistedUpdateState(args: { installedAt: args.now, releaseNotesUrl: buildReleaseNotesUrl(args.currentVersion, args.releaseNotesBaseUrl) ?? pendingInstall.releaseNotesUrl, - githubReleaseUrl: buildGithubReleaseUrl(args.currentVersion), + githubReleaseUrl: buildGithubReleaseUrl(args.currentVersion, args.releaseRepository), }; cacheCleanupReason = "installed"; nextState.failedInstallAttempts = undefined; @@ -337,12 +342,22 @@ function reconcilePersistedUpdateState(args: { changed = true; } + const recentlyInstalled = withGithubReleaseUrl( + cloneRecentlyInstalledUpdate(nextState.recentlyInstalledUpdate ?? null), + args.releaseRepository, + ); + if ( + recentlyInstalled + && nextState.recentlyInstalledUpdate?.githubReleaseUrl !== recentlyInstalled.githubReleaseUrl + ) { + nextState.recentlyInstalledUpdate = recentlyInstalled; + changed = true; + } + return { state: nextState, changed, - recentlyInstalled: withGithubReleaseUrl( - cloneRecentlyInstalledUpdate(nextState.recentlyInstalledUpdate ?? null), - ), + recentlyInstalled, cacheCleanupReason, failedInstall, }; @@ -374,6 +389,7 @@ export function createAutoUpdateService({ now = () => new Date().toISOString(), nowMs = () => Date.now(), releaseNotesBaseUrl = DEFAULT_RELEASE_NOTES_BASE_URL, + releaseRepository = DEFAULT_RELEASE_REPOSITORY, startupDelayMs = 5_000, periodicCheckMs = 30 * 60 * 1_000, updaterCacheDir, @@ -411,13 +427,10 @@ export function createAutoUpdateService({ if (overrideFeedUrl) { updater.setFeedURL?.({ provider: "generic", url: overrideFeedUrl }); logger.info("autoUpdate.feed_override", { url: overrideFeedUrl }); - } else { - updater.setFeedURL?.({ - provider: "github", - owner: "arul28", - repo: "ADE", - }); } + // Packaged builds intentionally keep electron-builder's generated + // app-update.yml as the sole update-feed authority. Calling setFeedURL here + // would silently replace build-time repository configuration. } catch (error) { logger.warn("autoUpdate.feed_config_failed", { message: formatErrorMessage(error), @@ -429,6 +442,7 @@ export function createAutoUpdateService({ currentVersion, now: now(), releaseNotesBaseUrl, + releaseRepository, }); if (initialState.changed) { writeGlobalState(globalStatePath, initialState.state); diff --git a/apps/desktop/src/main/services/updates/autoUpdateVersions.ts b/apps/desktop/src/main/services/updates/autoUpdateVersions.ts index 2df9b95f4..5136bd58a 100644 --- a/apps/desktop/src/main/services/updates/autoUpdateVersions.ts +++ b/apps/desktop/src/main/services/updates/autoUpdateVersions.ts @@ -1,4 +1,5 @@ export const DEFAULT_RELEASE_NOTES_BASE_URL = "https://www.ade-app.dev"; +export const DEFAULT_RELEASE_REPOSITORY = "arul28/ADE"; function parseVersion(version: string): { core: number[]; @@ -61,11 +62,17 @@ export function buildReleaseNotesUrl( return `${normalizedBaseUrl}/docs/changelog/${encodeURIComponent(`v${normalizedVersion}`)}`; } -// Deterministic GitHub release page for a version tag, e.g. -// https://github.com/arul28/ADE/releases/tag/v1.2.18 — the same repo the -// updater feed targets. -export function buildGithubReleaseUrl(version: string): string | null { +// Deterministic GitHub release page for a version tag in the same repository +// that electron-builder targets for packaged updates. +export function buildGithubReleaseUrl( + version: string, + repository = DEFAULT_RELEASE_REPOSITORY, +): string | null { const normalizedVersion = version.trim().replace(/^v/i, ""); - if (!normalizedVersion) return null; - return `https://github.com/arul28/ADE/releases/tag/${encodeURIComponent(`v${normalizedVersion}`)}`; + const normalizedRepository = repository.trim().replace(/^\/+|\/+$/g, ""); + if ( + !normalizedVersion + || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalizedRepository) + ) return null; + return `https://github.com/${normalizedRepository}/releases/tag/${encodeURIComponent(`v${normalizedVersion}`)}`; } diff --git a/apps/web/src/app/pages/DownloadPage.tsx b/apps/web/src/app/pages/DownloadPage.tsx index 9e0fd3ecf..d12474e67 100644 --- a/apps/web/src/app/pages/DownloadPage.tsx +++ b/apps/web/src/app/pages/DownloadPage.tsx @@ -20,6 +20,8 @@ import { type PlatformHint = "mac" | "windows" | "linux" | "ios" | "unknown"; +const WINDOWS_DOWNLOAD_ENABLED = import.meta.env.VITE_ADE_WINDOWS_DOWNLOAD_ENABLED === "1"; + function detectPlatform(): PlatformHint { const ua = typeof navigator !== "undefined" ? navigator.userAgent.toLowerCase() : ""; // navigator.platform is deprecated; used only as an iPad-spoof fallback. @@ -34,6 +36,7 @@ function detectPlatform(): PlatformHint { function downloadCtaForFeature(feature: MarketingFeature) { if (feature === MARKETING_FEATURES.DOWNLOAD_MAC) return MARKETING_CTA_LABELS.DOWNLOAD_MAC; + if (feature === MARKETING_FEATURES.DOWNLOAD_WINDOWS) return MARKETING_CTA_LABELS.DOWNLOAD_WINDOWS; if (feature === MARKETING_FEATURES.DOWNLOAD_IOS) return MARKETING_CTA_LABELS.DOWNLOAD_IOS; return undefined; } @@ -73,11 +76,17 @@ export function DownloadPage() { key: "windows" as const, title: "Windows", icon: , - note: "Installer builds are not published yet.", - hint: "Use the source build path for now.", - actionHref: LINKS.releases, - actionLabel: "Check releases", - analyticsFeature: MARKETING_FEATURES.VIEW_RELEASES, + note: WINDOWS_DOWNLOAD_ENABLED + ? "Windows 10 and 11 x64 installer from GitHub Releases." + : "Windows x64 preview builds are in release validation.", + hint: WINDOWS_DOWNLOAD_ENABLED + ? "The NSIS installer includes the app, ade CLI, ade code, and the background ADE brain." + : "Public download stays off until the signed Windows release is approved.", + actionHref: WINDOWS_DOWNLOAD_ENABLED ? LINKS.releasesLatest : LINKS.releases, + actionLabel: WINDOWS_DOWNLOAD_ENABLED ? "Download for Windows" : "View Windows release status", + analyticsFeature: WINDOWS_DOWNLOAD_ENABLED + ? MARKETING_FEATURES.DOWNLOAD_WINDOWS + : MARKETING_FEATURES.VIEW_RELEASES, }, { key: "linux" as const, @@ -119,8 +128,8 @@ export function DownloadPage() {

- Get ADE for Mac from GitHub Releases, install the iOS companion from TestFlight, or build from source. The - computer install includes the app, ade CLI, ade code, and the background ADE brain. + Get ADE for macOS from GitHub Releases, follow Windows x64 release validation, install the iOS companion + from TestFlight, or build from source. Computer installs include the app, ade CLI, ade code, and ADE brain.

@@ -129,6 +138,11 @@ export function DownloadPage() { Download for Mac + {WINDOWS_DOWNLOAD_ENABLED ? ( + + Download for Windows + + ) : null} Download for iOS @@ -249,9 +263,9 @@ export function DownloadPage() { />
- Official macOS releases are intended to be signed and notarized so ADE can open normally, keep using - in-app updates, and refresh the bundled brain after an update. Older beta artifacts may still need the - legacy Gatekeeper workaround. + Official macOS releases are signed and notarized. Windows pull-request previews may be unsigned and are + intended only for internal testing; public Windows downloads use the signed installer from the approved + GitHub Release. Cloud features are optional. ADE is designed to keep the repo authoritative and treat hosted results as diff --git a/apps/web/src/lib/marketingAnalytics.test.ts b/apps/web/src/lib/marketingAnalytics.test.ts index 55be1d0ee..aa468bd24 100644 --- a/apps/web/src/lib/marketingAnalytics.test.ts +++ b/apps/web/src/lib/marketingAnalytics.test.ts @@ -102,6 +102,27 @@ test("routes an annotated browser CTA once without duplicating its feature event assert.deepEqual(captured, ["cta:download_for_mac:home:hero"]); }); +test("routes the Windows download CTA through the allowlists", () => { + const captured: string[] = []; + const attributes = new Map([ + ["data-ade-analytics-cta", MARKETING_CTA_LABELS.DOWNLOAD_WINDOWS], + ["data-ade-analytics-position", MARKETING_CTA_POSITIONS.DOWNLOAD_PAGE], + ["data-ade-analytics-feature", MARKETING_FEATURES.DOWNLOAD_WINDOWS], + ]); + const annotatedTarget = { + closest(selector: string) { + return selector === "[data-ade-analytics-cta]" || selector === "[data-ade-analytics-feature]" + ? { getAttribute: (name: string) => attributes.get(name) ?? null } + : null; + }, + }; + + assert.equal(routeMarketingAnalyticsClick(annotatedTarget, "/download", { + captureCta: (label, screen, position) => captured.push(`cta:${label}:${screen}:${position}`), + captureFeature: (feature, screen) => captured.push(`feature:${feature}:${screen}`), + }), "cta"); + assert.deepEqual(captured, ["cta:download_for_windows:download:download_page"]); +}); test("manual payload contains only anonymous allowlisted properties", () => { const { analytics, payloads } = createHarness(); assert.equal(analytics.captureFeature(MARKETING_FEATURES.DOWNLOAD_MAC, MARKETING_SCREENS.HOME), "sent"); diff --git a/apps/web/src/lib/marketingAnalytics.ts b/apps/web/src/lib/marketingAnalytics.ts index fb5861c86..abb73669a 100644 --- a/apps/web/src/lib/marketingAnalytics.ts +++ b/apps/web/src/lib/marketingAnalytics.ts @@ -24,6 +24,7 @@ export type MarketingScreen = (typeof MARKETING_SCREENS)[keyof typeof MARKETING_ export const MARKETING_FEATURES = { DOWNLOAD_MAC: "download_mac", + DOWNLOAD_WINDOWS: "download_windows", DOWNLOAD_IOS: "download_ios", OPEN_WEB_CLIENT: "open_web_client", VIEW_DOCS: "view_docs", @@ -56,6 +57,7 @@ export type MarketingFeature = (typeof MARKETING_FEATURES)[keyof typeof MARKETIN export const MARKETING_CTA_LABELS = { DOWNLOAD_MAC: "download_for_mac", + DOWNLOAD_WINDOWS: "download_for_windows", DOWNLOAD_IOS: "download_for_ios", GET_STARTED_FREE: "get_started_free", OPEN_WEB_CLIENT: "open_web_client", From f63f742d4c95eee0b5508bbccc5a6b2902ae5296 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 19:26:37 -0400 Subject: [PATCH 02/12] refactor(windows): defer release repository wiring to packaging Keep PR 1 independently buildable while preserving the cumulative #999 release configuration in its owning layer. Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- apps/desktop/src/main/main.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 3445ba1ab..25b5ca562 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 { @@ -7066,6 +7094,7 @@ app.whenReady().then(async () => { closeCurrentProject, closeProjectByPath, globalStatePath, + releaseRepository: packagedReleaseRepository, builtInBrowserService, productAnalyticsService, publishAttentionNotchSnapshot: (snapshot: AttentionSnapshot) => { From 701d3dada557871d768e2d285a3f242ee8eb3281 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 19:31:38 -0400 Subject: [PATCH 03/12] refactor(windows): own release IPC metadata in packaging Restore the cumulative #999 release repository contract at its packaging layer. Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- apps/desktop/src/main/services/ipc/registerIpc.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 23c652788..679ce07d0 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -8,6 +8,7 @@ import { DEFAULT_AUTO_UPDATE_PREFERENCES } from "../../../shared/types"; import { buildGithubReleaseUrl, compareUpdateVersions, + DEFAULT_RELEASE_REPOSITORY, } from "../updates/autoUpdateVersions"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -1591,6 +1592,7 @@ export function registerIpc({ closeCurrentProject, closeProjectByPath, globalStatePath, + releaseRepository = DEFAULT_RELEASE_REPOSITORY, builtInBrowserService, productAnalyticsService, publishAttentionNotchSnapshot, @@ -1619,6 +1621,7 @@ export function registerIpc({ closeCurrentProject: () => Promise; closeProjectByPath: (projectRoot: string) => Promise; globalStatePath: string; + releaseRepository?: string; builtInBrowserService?: ReturnType | null; productAnalyticsService?: ProductAnalyticsService; publishAttentionNotchSnapshot?: (snapshot: AttentionSnapshot) => void; @@ -3987,7 +3990,7 @@ export function registerIpc({ if (!version) return null; return { version, - htmlUrl: buildGithubReleaseUrl(version), + htmlUrl: buildGithubReleaseUrl(version, releaseRepository), publishedAt: null, updateAvailable: compareUpdateVersions(version, app.getVersion()) > 0, }; From 794abdc05f457e99045646d3fde9f9701bbf88aa Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 20:31:16 -0400 Subject: [PATCH 04/12] feat(windows): complete packaging and runtime readiness Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- .github/workflows/ci.yml | 40 ++- .github/workflows/release-core.yml | 69 ++-- apps/ade-cli/README.md | 12 +- apps/ade-cli/scripts/build-static.mjs | 21 +- apps/ade-cli/scripts/install-runtime.ps1 | 299 ++++++++++++++++++ apps/ade-cli/scripts/package-native-deps.mjs | 12 +- apps/ade-cli/src/commands/brainUpdate.test.ts | 113 ++++++- apps/ade-cli/src/commands/brainUpdate.ts | 150 ++++++++- apps/ade-cli/src/commands/doctor.test.ts | 14 + apps/ade-cli/src/commands/doctor.ts | 65 +++- apps/desktop/build/installer.nsh | 34 ++ apps/desktop/package.json | 14 +- .../scripts/ade-cli-windows-wrapper.cmd | 17 +- .../scripts/after-pack-runtime-fixes.cjs | 14 +- apps/desktop/scripts/run-electron-builder.mjs | 48 ++- .../scripts/validate-win-artifacts.mjs | 30 +- .../desktop/scripts/windows-install-setup.ps1 | 166 ++++++++++ .../windows-installed-product-smoke.ps1 | 228 +++++++++++++ .../scripts/windows-package-identity.mjs | 26 ++ .../scripts/windows-release-contract.test.mjs | 113 ++++++- .../scripts/windows-uninstall-cleanup.ps1 | 232 +++++++++++--- .../windows-uninstall-cleanup.test.mjs | 269 +++++++++++++++- .../remoteRuntime/remoteBootstrap.test.ts | 15 + .../services/remoteRuntime/remoteBootstrap.ts | 20 +- docs/ARCHITECTURE.md | 23 +- docs/development/windows-port-lane.md | 59 +++- docs/features/remote-runtime/README.md | 10 +- 27 files changed, 1958 insertions(+), 155 deletions(-) create mode 100644 apps/ade-cli/scripts/install-runtime.ps1 create mode 100644 apps/desktop/scripts/windows-install-setup.ps1 create mode 100644 apps/desktop/scripts/windows-installed-product-smoke.ps1 create mode 100644 apps/desktop/scripts/windows-package-identity.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 436a4afc0..b1984e370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -356,12 +356,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 +393,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 +410,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 @@ -528,7 +536,7 @@ jobs: package-win: needs: build-runtime-binaries runs-on: windows-latest - timeout-minutes: 45 + timeout-minutes: 60 steps: - uses: actions/checkout@v4 @@ -556,13 +564,37 @@ jobs: - name: Validate Windows release contract run: npm --prefix apps/desktop run test:win:release-contract - - name: Build and smoke unsigned Windows preview + - 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-*-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-*-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: diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 243fd017a..f01e43b35 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -72,11 +72,16 @@ jobs: env: BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} + WINDOWS_UPDATE_PROOF_APPROVED: ${{ vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED }} run: | if [ "$PUBLISH_WINDOWS" = "1" ] && [ "$BUILD_WINDOWS" != "1" ]; then echo "::error::ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1." exit 1 fi + if [ "$PUBLISH_WINDOWS" = "1" ] && [ "$WINDOWS_UPDATE_PROOF_APPROVED" != "1" ]; then + echo "::error::ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires approved two-version installed-update proof." + exit 1 + fi build-mac-release: needs: @@ -284,15 +289,15 @@ jobs: - name: Require Windows Authenticode signing secrets shell: pwsh env: - CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD }} - ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} - ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} + WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} + WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} run: | - if ([string]::IsNullOrWhiteSpace($env:CSC_LINK) -or [string]::IsNullOrWhiteSpace($env:CSC_KEY_PASSWORD)) { - throw "Public Windows releases require WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD (or the WIN_* aliases)." + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CSC_LINK) -or [string]::IsNullOrWhiteSpace($env:WINDOWS_CSC_KEY_PASSWORD)) { + throw "Public Windows releases require WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD." } - if ([string]::IsNullOrWhiteSpace($env:ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT) -and [string]::IsNullOrWhiteSpace($env:ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT)) { + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_EXPECTED_SUBJECT) -and [string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_EXPECTED_THUMBPRINT)) { throw "Public Windows releases require WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT." } - name: Download ADE runtime binaries @@ -325,12 +330,19 @@ jobs: ADE_RELEASE_REPOSITORY: ${{ github.repository }} ADE_POSTHOG_PROJECT_TOKEN: ${{ secrets.ADE_POSTHOG_PROJECT_TOKEN }} ADE_POSTHOG_HOST: ${{ secrets.ADE_POSTHOG_HOST }} - CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK || secrets.WIN_CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD || secrets.WIN_CSC_KEY_PASSWORD }} - ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} - ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} + WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} + WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} 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-*-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 artifacts to workflow run uses: actions/upload-artifact@v4 with: @@ -349,12 +361,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 }} @@ -454,8 +473,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" @@ -470,7 +490,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 @@ -484,7 +504,10 @@ jobs: && needs.build-mac-release.result == 'success' && ( vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1' - || needs.build-win-release.result == 'success' + || ( + vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' + && needs.build-win-release.result == 'success' + ) ) }} needs: @@ -527,7 +550,7 @@ jobs: 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_SIGNED_BUILD_ENABLED == '1' && vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} + if: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' }} uses: actions/download-artifact@v4 with: name: ade-win-release-${{ inputs.release_tag }} @@ -544,11 +567,12 @@ jobs: run: | cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh chmod 755 release-assets/runtime/install.sh + cp apps/ade-cli/scripts/install-runtime.ps1 release-assets/runtime/install.ps1 - name: Generate standalone runtime checksums run: | set -euo pipefail - (cd release-assets/runtime && sha256sum install.sh ade-* | LC_ALL=C sort -k2 > SHA256SUMS) + (cd release-assets/runtime && sha256sum install.sh install.ps1 ade-* | LC_ALL=C sort -k2 > SHA256SUMS) - name: Validate publish asset manifest run: | @@ -586,11 +610,14 @@ jobs: echo "::error::Standalone runtime installer is not executable." exit 1 fi + require_file 'release-assets/runtime/install.ps1' 'standalone Windows runtime installer' 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" + for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-x64; do + binary="release-assets/runtime/ade-$target" + if [ "$target" = "win32-x64" ]; then binary="$binary.exe"; fi + 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" @@ -601,7 +628,7 @@ jobs: done - name: Validate gated Windows publish asset manifest - if: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' }} + if: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1' && vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1' }} run: | set -euo pipefail shopt -s nullglob @@ -622,6 +649,7 @@ jobs: GH_REPO: ${{ github.repository }} BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} + WINDOWS_UPDATE_PROOF_APPROVED: ${{ vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED }} run: | shopt -s nullglob # The per-arch macOS zips + latest-mac.yml are what electron-updater @@ -633,13 +661,14 @@ jobs: release-assets/mac/*.zip release-assets/mac/latest-mac.yml release-assets/runtime/install.sh + release-assets/runtime/install.ps1 release-assets/runtime/SHA256SUMS release-assets/runtime/ade-* ) # This repository variable stays disabled until the signed installer # passes the clean-host release checks. - if [ "$BUILD_WINDOWS" = "1" ] && [ "$PUBLISH_WINDOWS" = "1" ]; then + if [ "$BUILD_WINDOWS" = "1" ] && [ "$PUBLISH_WINDOWS" = "1" ] && [ "$WINDOWS_UPDATE_PROOF_APPROVED" = "1" ]; then files+=( release-assets/win/*.exe release-assets/win/*.exe.blockmap diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index d5164e4da..bcbaae946 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,7 @@ 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. + 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: @@ -164,7 +170,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 and restores the previous executable, native tree, and service if promotion fails. 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..a68847d99 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; @@ -308,7 +309,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 +337,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 +353,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..5bd2aa717 --- /dev/null +++ b/apps/ade-cli/scripts/install-runtime.ps1 @@ -0,0 +1,299 @@ +[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]$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) { + $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..310b58533 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; } @@ -212,12 +213,13 @@ async function writeManifest(bundleRoot, target, packages) { // 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"]); +const CRSQLITE_REQUIRED_TARGETS = new Set(["darwin-arm64", "darwin-x64", "win32-x64"]); 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}).`); } diff --git a/apps/ade-cli/src/commands/brainUpdate.test.ts b/apps/ade-cli/src/commands/brainUpdate.test.ts index 6f077bfec..7dbc38e51 100644 --- a/apps/ade-cli/src/commands/brainUpdate.test.ts +++ b/apps/ade-cli/src/commands/brainUpdate.test.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { brainUpdateAssetUrl, detectRuntimeTarget, @@ -17,8 +17,9 @@ 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"); @@ -42,7 +43,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 +224,111 @@ 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("rolls back promoted assets when the service restart fails", 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 f2b718c05..a467d4f4d 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 {} @@ -108,10 +109,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}`; @@ -395,6 +398,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"); } @@ -525,6 +581,11 @@ async function applyStagedBrainUpdate( 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 = { @@ -545,9 +606,59 @@ async function applyStagedBrainUpdate( const cleanupStagingDir = async () => { await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); }; + const platform = deps.platform ?? process.platform; + let windowsServiceStopped = false; + let windowsServiceWasInstalled = false; + const serviceEnv = () => ({ + ...runtimeSidecarEnv(process.env, 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( @@ -619,10 +730,7 @@ async function applyStagedBrainUpdate( } 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, @@ -639,7 +747,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, @@ -669,7 +780,11 @@ async function applyStagedBrainUpdate( message: "ADE brain updated and service restart requested.", }; } 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, @@ -717,13 +832,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 = { @@ -763,8 +883,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; @@ -775,7 +895,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/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/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh index d777b1e8f..3481c68aa 100644 --- a/apps/desktop/build/installer.nsh +++ b/apps/desktop/build/installer.nsh @@ -1,3 +1,37 @@ +!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} +!macroend + !macro customUnInstall DetailPrint "Removing the ADE background service and terminal command..." StrCpy $2 "stable" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 14cdfa7b6..2bc67660b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -337,6 +337,10 @@ }, "artifactName": "${productName}-${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" @@ -354,7 +358,15 @@ ] }, "nsis": { - "include": "build/installer.nsh" + "include": "build/installer.nsh", + "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-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 94debff58..56b3b088b 100644 --- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -197,12 +197,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,7 +416,7 @@ 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)}`); } @@ -423,6 +424,11 @@ module.exports = async function afterPack(context) { 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"); + 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/run-electron-builder.mjs b/apps/desktop/scripts/run-electron-builder.mjs index c1dfe6159..1820cc2b4 100644 --- a/apps/desktop/scripts/run-electron-builder.mjs +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { resolveWindowsPackageIdentity } from "./windows-package-identity.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const desktopRoot = path.resolve(scriptDir, ".."); @@ -14,6 +15,21 @@ const configuredRepository = ( || `${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; +const canonicalWindowsCscLink = process.env.WINDOWS_CSC_LINK; +const canonicalWindowsCscKeyPassword = process.env.WINDOWS_CSC_KEY_PASSWORD; +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."); +} +const baseChildEnv = { ...process.env }; +delete baseChildEnv.CSC_LINK; +delete baseChildEnv.CSC_KEY_PASSWORD; +delete baseChildEnv.WINDOWS_CSC_LINK; +delete baseChildEnv.WINDOWS_CSC_KEY_PASSWORD; if (!repositoryMatch) { throw new Error( @@ -22,7 +38,8 @@ if (!repositoryMatch) { } if (requireSigning) { - const missingSecrets = ["CSC_LINK", "CSC_KEY_PASSWORD"].filter((name) => !process.env[name]?.trim()); + const missingSecrets = ["WINDOWS_CSC_LINK", "WINDOWS_CSC_KEY_PASSWORD"] + .filter((name) => !process.env[name]?.trim()); if (missingSecrets.length > 0) { throw new Error( `Signed Windows packaging requires ${missingSecrets.join(" and ")}. ` @@ -43,17 +60,38 @@ const args = [ `--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}`, + `--config.fileAssociations.name=${channelIdentity.fileClass}`, + `--config.fileAssociations.description=${configuredFileAssociation.description ?? "ADE files"}`, + ...configuredFileAssociation.ext.map((extension) => `--config.fileAssociations.ext=${extension}`), ...(requireSigning ? ["--config.forceCodeSigning=true"] : []), ]; console.log( - `[windows-package] Building for ${owner}/${repo}${requireSigning ? " with required Authenticode signing" : " (unsigned allowed)"}.`, + `[windows-package] Building ${channelIdentity.productName} for ${owner}/${repo}${requireSigning ? " with required Authenticode signing" : " (unsigned allowed)"}.`, ); -const child = spawn(electronBuilderBin, args, { +const childEnv = { + ...baseChildEnv, + ADE_PACKAGE_CHANNEL: packageChannel === "stable" ? "" : packageChannel, + ADE_DESKTOP_APP_NAME: channelIdentity.productName, + ...(requireSigning + ? { + CSC_LINK: canonicalWindowsCscLink, + CSC_KEY_PASSWORD: canonicalWindowsCscKeyPassword, + } + : {}), +}; +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: process.env, + env: childEnv, stdio: "inherit", - shell: process.platform === "win32", + shell: false, windowsHide: process.platform === "win32", }); child.once("error", (error) => { diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index 890bf3dd5..9de03f77f 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -8,6 +8,10 @@ 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 { + resolveWindowsPackageIdentity, + windowsInstallerPattern, +} from "./windows-package-identity.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const desktopRoot = path.resolve(__dirname, ".."); @@ -17,7 +21,8 @@ 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 @@ -83,14 +88,14 @@ function normalizeCertificateThumbprint(value) { function expectedWindowsSigningIdentity() { if (!shouldRequireSignedArtifacts()) return null; - const subject = process.env.ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT?.trim() ?? ""; + const subject = process.env.WINDOWS_SIGNING_EXPECTED_SUBJECT?.trim() ?? ""; const thumbprint = normalizeCertificateThumbprint( - process.env.ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT, + process.env.WINDOWS_SIGNING_EXPECTED_THUMBPRINT, ); if (!subject && !thumbprint) { fail( - "Signed Windows validation requires ADE_WINDOWS_EXPECTED_PUBLISHER_SUBJECT " + - "or ADE_WINDOWS_EXPECTED_CERTIFICATE_THUMBPRINT so the release cannot be signed by an unexpected publisher.", + "Signed Windows validation requires WINDOWS_SIGNING_EXPECTED_SUBJECT " + + "or WINDOWS_SIGNING_EXPECTED_THUMBPRINT so the release cannot be signed by an unexpected publisher.", ); } return { subject, thumbprint }; @@ -210,10 +215,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 []; @@ -256,6 +257,7 @@ 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("build/installer.nsh", "Windows NSIS customization"); requireFile("vendor/crsqlite/win32-x64/crsqlite.dll", "Windows cr-sqlite extension"); @@ -273,6 +275,14 @@ function validatePreflight() { if (pkg.build?.win?.icon !== "build/icon.ico") { fail("package.json build.win.icon must point to build/icon.ico"); } + 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"); + } const winTargets = parseWinTargets(); if (winTargets.length === 0) { @@ -840,7 +850,7 @@ async function validateAuthenticodeSignature(filePath, description, expectedIden 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 = 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..5a5956cd7 --- /dev/null +++ b/apps/desktop/scripts/windows-package-identity.mjs @@ -0,0 +1,26 @@ +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, + appId, + executableName: `${productName}.exe`, + fileClass: `${appId}.files`, + }; +} + +export function windowsInstallerPattern(identity) { + const escapedProductName = identity.productName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escapedProductName}-.+-win-x64\\.exe$`); +} diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index a41ded21a..04bdb9545 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -5,6 +5,10 @@ import test from "node:test"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; +import { + resolveWindowsPackageIdentity, + windowsInstallerPattern, +} from "./windows-package-identity.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const desktopRoot = path.resolve(scriptDir, ".."); @@ -128,8 +132,11 @@ test("public Windows packaging fails closed on Authenticode signing", () => { assert.match(windowsRelease, /npm run dist:win:signed/); assert.match(windowsRelease, /ADE_RELEASE_REPOSITORY:\s*\$\{\{ github\.repository \}\}/); assert.match(windowsRelease, /WINDOWS_CSC_LINK/); + assert.match(windowsRelease, /WINDOWS_CSC_KEY_PASSWORD/); + assert.doesNotMatch(windowsRelease, /WIN_CSC_LINK|WIN_CSC_KEY_PASSWORD/); assert.match(windowsRelease, /WINDOWS_SIGNING_EXPECTED_SUBJECT/); assert.match(windowsRelease, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT/); + 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( @@ -141,15 +148,72 @@ test("public Windows packaging fails closed on Authenticode signing", () => { 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.CSC_LINK; - delete env.CSC_KEY_PASSWORD; + delete env.WINDOWS_CSC_LINK; + delete env.WINDOWS_CSC_KEY_PASSWORD; 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 CSC_LINK and CSC_KEY_PASSWORD/); + assert.match(`${result.stdout}\n${result.stderr}`, /Signed Windows packaging requires WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD/); +}); + +test("Windows packaging accepts signing material only through canonical inputs", () => { + assert.match(electronBuilderWrapper, /canonicalWindowsCscLink = process\.env\.WINDOWS_CSC_LINK/); + assert.match(electronBuilderWrapper, /canonicalWindowsCscKeyPassword = process\.env\.WINDOWS_CSC_KEY_PASSWORD/); + assert.match(electronBuilderWrapper, /delete baseChildEnv\.CSC_LINK/); + assert.match(electronBuilderWrapper, /delete baseChildEnv\.CSC_KEY_PASSWORD/); + assert.match(electronBuilderWrapper, /delete baseChildEnv\.WINDOWS_CSC_LINK/); + assert.match(electronBuilderWrapper, /delete baseChildEnv\.WINDOWS_CSC_KEY_PASSWORD/); + assert.match(electronBuilderWrapper, /CSC_LINK: canonicalWindowsCscLink/); + assert.match(electronBuilderWrapper, /CSC_KEY_PASSWORD: canonicalWindowsCscKeyPassword/); +}); + +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 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.match(winArtifactValidator, /windowsInstallerPattern\(packageIdentity\)/); +}); + +test("standalone releases include a checksummed Windows brain and PowerShell installer", () => { + const runtimeBuild = jobBlock(releaseWorkflow, "build-runtime-binaries", "publish-release"); + const publish = jobBlock(releaseWorkflow, "publish-release", null); + const installer = fs.readFileSync( + path.join(repoRoot, "apps", "ade-cli", "scripts", "install-runtime.ps1"), + "utf8", + ); + assert.match(runtimeBuild, /target: win32-x64[\s\S]*os: windows-latest[\s\S]*binary: ade-win32-x64\.exe/); + assert.match(publish, /install-runtime\.ps1 release-assets\/runtime\/install\.ps1/); + assert.match(publish, /sha256sum install\.sh install\.ps1 ade-\*/); + assert.match(publish, /darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-x64/); + assert.match(publish, /if \[ "\$target" = "win32-x64" \]; then binary="\$binary\.exe"/); + assert.match(installer, /Verify-Checksum/); + 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", () => { @@ -164,10 +228,13 @@ test("Windows release assets are validated and published as one release set", () assert.match(publish, /name: ade-win-release-/); assert.match(publish, /vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED != '1'[\s\S]*needs\.build-win-release\.result == 'success'/); assert.match(verify, /ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1 requires ADE_WINDOWS_SIGNED_BUILD_ENABLED=1/); + assert.match(verify, /ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED/); + assert.match(verify, /requires approved two-version installed-update proof/); assert.match(publish, /ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' && vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED == '1'/); + assert.match(publish, /ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED == '1'/); assert.match(publish, /BUILD_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED \}\}/); assert.match(publish, /PUBLISH_WINDOWS: \$\{\{ vars\.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED \}\}/); - assert.match(publish, /if \[ "\$BUILD_WINDOWS" = "1" \] && \[ "\$PUBLISH_WINDOWS" = "1" \]; then/); + assert.match(publish, /if \[ "\$BUILD_WINDOWS" = "1" \] && \[ "\$PUBLISH_WINDOWS" = "1" \] && \[ "\$WINDOWS_UPDATE_PROOF_APPROVED" = "1" \]; then/); assert.match(publish, /release-assets\/win\/\*\.exe/); assert.match(publish, /release-assets\/win\/\*\.exe\.blockmap/); assert.match(publish, /release-assets\/win\/latest\.yml/); @@ -176,8 +243,17 @@ test("Windows release assets are validated and published as one release set", () assert.match(publish, /if \[ "\$is_draft" != "true" \]; then/); }); -test("Windows NSIS uninstall removes ADE-owned machine integration", () => { +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", @@ -187,6 +263,12 @@ test("Windows NSIS uninstall removes ADE-owned machine integration", () => { 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\}"/); @@ -197,10 +279,17 @@ test("Windows NSIS uninstall removes ADE-owned machine integration", () => { assert.match(cleanup, /-Wait/); assert.match(cleanup, /cleanupProcess\.ExitCode/); assert.match(cleanup, /ADE_PACKAGE_CHANNEL = \$normalizedPackageChannel/); - assert.match(cleanup, /ADE_HOME = Join-Path/); + 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", () => { @@ -215,6 +304,18 @@ 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/); diff --git a/apps/desktop/scripts/windows-uninstall-cleanup.ps1 b/apps/desktop/scripts/windows-uninstall-cleanup.ps1 index 1a71e1999..9920afa6b 100644 --- a/apps/desktop/scripts/windows-uninstall-cleanup.ps1 +++ b/apps/desktop/scripts/windows-uninstall-cleanup.ps1 @@ -4,9 +4,11 @@ param( [string]$InstallDir, [string]$AppExecutableName = "", [string]$PackageChannel = "stable", + [string]$AdeHome = "", [string]$CliBinDir = "", [switch]$SkipServiceRemoval, - [switch]$SkipUserPathUpdate + [switch]$SkipUserPathUpdate, + [switch]$SkipProtocolRemoval ) $ErrorActionPreference = "Stop" @@ -59,7 +61,8 @@ namespace Ade.Windows { function Test-CliShimOwnedByInstall( [string]$ShimPath, - [string]$ExpectedCliDir + [string]$ExpectedCliDir, + [string[]]$ExpectedWrapperNames ) { $contents = Get-Content -LiteralPath $ShimPath -Raw -ErrorAction Stop foreach ($line in ($contents -split "`r?`n")) { @@ -71,11 +74,10 @@ function Test-CliShimOwnedByInstall( if (-not $match.Success) { continue } $targetPath = $match.Groups["target"].Value - if (-not [string]::Equals( - [System.IO.Path]::GetFileName($targetPath), - "ade.cmd", - [System.StringComparison]::OrdinalIgnoreCase - )) { continue } + $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)) @@ -125,6 +127,119 @@ namespace Ade.Windows { } } +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) { @@ -141,53 +256,56 @@ if (-not $SkipServiceRemoval) { 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 (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) { - throw "Cannot remove the ADE background service because $normalizedAppExecutableName is missing from $resolvedInstallDir." - } - if (-not (Test-Path -LiteralPath $cliPath -PathType Leaf)) { - throw "Cannot remove the ADE background service because the packaged CLI is missing from $resolvedInstallDir." - } - - $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 - $homeName = if ($normalizedPackageChannel -eq "stable") { ".ade" } else { ".ade-$normalizedPackageChannel" } - $env:ADE_HOME = Join-Path ([System.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($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)." + 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 } - } 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 } } @@ -200,9 +318,13 @@ if ([string]::IsNullOrWhiteSpace($CliBinDir)) { $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) { + if (Test-CliShimOwnedByInstall $shim.FullName $packagedCliDir $expectedWrapperNames) { Remove-Item -LiteralPath $shim.FullName -Force -ErrorAction Stop } } @@ -241,3 +363,9 @@ if ($remainingAdeShims.Count -eq 0 -and (Test-Path -LiteralPath $resolvedCliBinD 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 index 425b27cea..51b5c1da6 100644 --- a/apps/desktop/scripts/windows-uninstall-cleanup.test.mjs +++ b/apps/desktop/scripts/windows-uninstall-cleanup.test.mjs @@ -6,6 +6,9 @@ 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)}`; @@ -53,6 +56,29 @@ function shortWindowsPath(value) { 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) => { @@ -99,6 +125,241 @@ test("Windows uninstall cleanup removes only CLI shims owned by this installatio 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) => { @@ -108,8 +369,13 @@ test("Windows uninstall cleanup uses the packaged executable and channel identit const cliRoot = path.join(installDir, "resources", "ade-cli"); const cliBinDir = path.join(tempRoot, "empty user bin"); const resultPath = path.join(tempRoot, "service-cleanup.json"); - fs.mkdirSync(cliRoot, { recursive: true }); + 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"); @@ -165,6 +431,7 @@ test("Windows uninstall cleanup uses the packaged executable and channel identit 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", { diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 61b4746a1..820d4e00b 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -13,6 +13,7 @@ import { bootstrapRemoteRuntime, buildRemoteRuntimeEnvironmentPrefix, coerceProjects, + formatOpenSshSpawnError, normalizeRemoteArch, normalizeRuntimeVersion, resolveRemoteRuntimeLayout, @@ -83,6 +84,20 @@ describe("normalizeRemoteArch", () => { }); }); +describe("formatOpenSshSpawnError", () => { + it("turns a missing Windows OpenSSH client into an actionable prerequisite diagnostic", () => { + const error = Object.assign(new Error("spawn ssh ENOENT"), { code: "ENOENT" }); + expect(formatOpenSshSpawnError(error, "win32")).toMatch( + /Windows OpenSSH Client is unavailable.*Optional Features.*Add-WindowsCapability.*restart ADE/i, + ); + }); + + it("preserves ordinary OpenSSH spawn failures on other platforms", () => { + expect(formatOpenSshSpawnError(new Error("permission denied"), "linux")) + .toBe("SSH upload process failed: permission denied"); + }); +}); + describe("normalizeRuntimeVersion", () => { it("normalizes plain and prefixed ADE version output", () => { expect(normalizeRuntimeVersion("1.0.0-beta.1\n")).toBe("1.0.0-beta.1"); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index 10e736ed2..b12ed4b1e 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -807,6 +807,24 @@ const REMOTE_ARTIFACT_UPLOAD_WATCHDOG_SECONDS = Math.ceil( const REMOTE_ARTIFACT_UPLOAD_CHUNK_BYTES = 1024 * 1024; const REMOTE_ARTIFACT_UPLOAD_NO_PROGRESS_RETRIES = 2; +export function formatOpenSshSpawnError( + error: unknown, + platform: NodeJS.Platform = process.platform, +): string { + const message = error instanceof Error ? error.message : String(error); + const code = error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code ?? "") + : ""; + if (platform === "win32" && (code === "ENOENT" || /spawn\s+ssh\s+enoent/i.test(message))) { + return [ + "Windows OpenSSH Client is unavailable.", + "Install it from Settings > System > Optional Features > OpenSSH Client", + "or run `Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0` in an elevated PowerShell window, then restart ADE.", + ].join(" "); + } + return `SSH upload process failed: ${message}`; +} + function openSshArgsForRoute( target: RemoteRuntimeTarget, route: ConnectedSshRoute, @@ -1058,7 +1076,7 @@ async function uploadSshChunkViaOpenSsh( stderr += chunk.toString(); }); child.on("error", (error) => { - settle(new Error(`SSH upload process failed${uploadProgressSuffix()}: ${error.message}`)); + settle(new Error(`${formatOpenSshSpawnError(error)}${uploadProgressSuffix()}`)); }); child.on("close", (code, signal) => { if (code && code !== 0) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 214890b63..569898c86 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -188,7 +188,7 @@ Personal chat is an explicit machine-only variant of the typed chat family: `ade **Proof subcommands** — `ade proof capture` (alias of `screenshot`), `ade proof attach `, `ade proof record`, `ade proof launch`, `ade proof interact`, `ade proof list/status/environment/ingest`, `ade proof rm …`, `ade proof broken`, `ade proof prune [--broken]`, and `ade proof recover `. `attach` resolves relative paths from the caller's lane worktree, infers the artifact kind from the file extension, and routes through `ingest_computer_use_artifacts` with `backendStyle: "manual"`; the broker rejects non-evidence extensions and denied/escaped sources before inserting any row. Bare `proof prune` lists broken records, while `--broken` deletes them. Capture-style commands set `preferHeadless: true` on the plan so the connection layer drops to headless mode unless `--socket` is explicitly requested. Owner-aware proof subcommands accept `--owner-kind` / `--owner-id` (with `chat` and `pr` aliases) to layer an explicit owner on top of the inferred session identity. -**Bundled runtime artifacts.** Per-platform `ade-` binaries plus their native dep tarballs live under `apps/desktop/resources/runtime/`, with packaged ADE CLI resources providing the `ptyHostWorker.cjs` used by remote terminals. `release-core.yml` builds the cross-platform set, validates that every darwin/linux arm64/x64 runtime binary and native archive is present, and publishes those runtime assets plus `install.sh` and `SHA256SUMS` on the GitHub release. `bootstrapRemoteRuntime` uploads missing or hash-mismatched artifacts on first SSH connect from the desktop client. +**Bundled runtime artifacts.** Per-platform `ade-` binaries plus their native dep tarballs live under `apps/desktop/resources/runtime/`, with packaged ADE CLI resources providing the `ptyHostWorker.cjs` used by remote terminals. `release-core.yml` builds and validates the darwin/linux arm64/x64 pairs plus `ade-win32-x64.exe` and `ade-win32-x64.native.tar.gz`. Windows desktop packages include all four Darwin/Linux sidecar pairs, not just the host's Windows CLI resources, so a Windows client can bootstrap supported macOS/Linux SSH runtimes. The standalone Windows runtime, `install.ps1`, and its checksums remain behind the signed-build, public-release, and installed-update-proof gates. `bootstrapRemoteRuntime` uploads missing or hash-mismatched artifacts on first SSH connect from the desktop client; Windows as an SSH-bootstrap runtime target is still out of scope. **Headless install + update.** A standalone runtime can be installed on a headless machine without going through the desktop installer. Remote machines reached over SSH don't need this path: `bootstrapRemoteRuntime` uploads the desktop app's bundled runtime artifacts. @@ -200,6 +200,14 @@ ade brain update status --text Use `ADE_VERSION=vX.Y.Z` for a pinned release or `ADE_INSTALL_DIR` to choose the destination directory. The installer defaults to `$ADE_HOME/bin/ade`; both install and `ade brain update` verify downloaded runtime assets against `SHA256SUMS`. `ade brain update` stages the next release under `$ADE_HOME/runtime/updates/`, verifies the staged binary against the staged native deps, promotes the binary/deps into place, and restarts the per-user brain service. +Windows x64 uses the equivalent PowerShell installer: + +```powershell +irm https://github.com/arul28/ADE/releases/latest/download/install.ps1 | iex +``` + +It installs `ade-win32-x64.exe` as `%ADE_HOME%\bin\ade.exe`, transactionally stages the binary and native dependency tree, updates the current-user `PATH`, and registers the same per-user/channel brain service. Windows self-update stops the running executable before promotion and rolls back both the executable/native tree and service on failure. + **Health check (`ade doctor [--online] [--text]`).** `apps/ade-cli/src/commands/doctor.ts` connects to the machine brain over the local socket (bounded ~2 s) and prints one status row (`ok` / `warn` / `fail`) per subsystem: **App** (installed desktop version from the `.app` `Info.plist` vs the latest known version — read from disk, or from GitHub with `--online`), **Brain** (running version/pid/uptime plus any build-hash or role mismatch), **Wedge history** (the most recent recovered event-loop wedge, if any), **Sync port** (whether the shared listener bound the default `8787`, and the holders of the base ports when it drifted — with no visible holder reported as exactly that, since a root-owned holder such as `tailscaled` is invisible to a user-level probe and must be checked with `tailscale serve status` / `netstat -an -p tcp`), **Publish health** (the account-directory publisher's last-leg durations and slowest leg), **Relay** (end-to-end verified vs a classified failure — with a deliberate suppression, i.e. another ADE process on this machine owning the relay slot, outranking every other reason, since nothing downstream can succeed while it holds and no other reason tells the user what to do), and **Account** (signed-in state and source). The command exits non-zero when any row is `fail`. The row-evaluation logic (`evaluateDoctorRows`) is pure and dependency-injected so the desktop connection-doctor card and the CLI share one verdict. **Install + PATH wiring (when the desktop ships `ade`).** On macOS / Linux the desktop installer drops the launcher at `$HOME/.local/bin/ade`; on Windows it lands at `%LOCALAPPDATA%\ADE\bin\ade.cmd`. After a successful install on Windows, the packaged `.cmd` installer adds the target directory to HKCU `Environment\Path` when needed and broadcasts an environment-change notification. After a successful install on POSIX, `ensureUserBinOnShellPath` appends a marked `export PATH="$HOME/.local/bin:$PATH"` block to the user's shell rc (`.zshrc` for zsh, `.bashrc` for bash, `.profile` otherwise) iff (a) the install dir isn't already on the inherited `PATH` and (b) the file doesn't already contain the marker / line / target dir. The install IPC reply tells the renderer which profile was edited so the Settings/Onboarding UI can prompt the user to open a new terminal or `source` it. @@ -1464,7 +1472,8 @@ Stages: - `build` — desktop, ade-cli, and web built sequentially after install. - `validate-docs` — `node scripts/validate-docs.mjs`. 3. **Windows foundation proof** (`windows-foundation`) — a native `windows-latest` runner typechecks desktop and CLI code and exercises the per-user/channel service, filesystem layout, process/executable, named-pipe, PTY, packaged CR-SQLite, and platform-capability contracts. -4. **Gate** (`ci-pass`) — all required jobs, including `windows-foundation`, must pass (`if: always()` with failure/cancelled detection). +4. **Windows package proof** (`package-win`) — a `windows-latest` runner downloads every Darwin/Linux runtime sidecar, runs the release-contract test, builds an unsigned x64 NSIS preview, performs packaged CLI/TUI/PTY/provider/CR-SQLite/updater-authority smoke validation, then exercises fresh install, repair, reinstall, launch/deep-link registration, file association, startup registration, PATH ownership, and uninstall cleanup before uploading the installer, blockmap, and `latest.yml` for 14 days. +5. **Gate** (`ci-pass`) — all required jobs, including `windows-foundation` and `package-win`, must pass (`if: always()` with failure/cancelled detection). Sharding is required because the desktop suite is large enough to be slow in a single process. @@ -1487,10 +1496,12 @@ macOS: Windows: -- `npm run dist:win` — x64 installer via `electron-builder --win --x64`, wrapped with `validate:win:artifacts` (preflight) and `validate:win:release` (post-build) checks in `apps/desktop/scripts/validate-win-artifacts.mjs`. -- Windows-only wrappers for the bundled `ade` CLI ship in `apps/desktop/scripts/`: `ade-cli-windows-wrapper.cmd` (launcher) and `ade-cli-install-path.cmd` (idempotent PATH install helper). The platform-agnostic `.sh` wrapper covers macOS/Linux. -- The Windows installer bundles the prebuilt `cr-sqlite` native binary from `apps/desktop/vendor/crsqlite/win32-x64/` and a Windows node-pty ConPTY worker. `validate-win-artifacts.mjs` asserts each one is unpacked. -- GitHub Actions `release-core.yml` builds and validates Windows artifacts. The release job picks up `WINDOWS_CSC_LINK` / `WINDOWS_CSC_KEY_PASSWORD` (or legacy `WIN_CSC_*`) from secrets and forwards them as electron-builder's `CSC_LINK` / `CSC_KEY_PASSWORD` to sign the installer and `app.exe`; the desktop config sets SHA-256 hashing and the DigiCert RFC3161 timestamp server. When the secrets are absent, the workflow still produces unsigned Windows artifacts. +- `npm run dist:win` — unsigned x64 preview installer via the guarded `run-electron-builder.mjs` wrapper, with `validate:win:artifacts` (preflight) and `validate:win:release` (post-build) checks in `apps/desktop/scripts/validate-win-artifacts.mjs`. +- `npm run dist:win:signed` — production path. The wrapper requires canonical `WINDOWS_CSC_LINK` and `WINDOWS_CSC_KEY_PASSWORD`, maps them only into the isolated electron-builder child process, and enables fail-closed code signing; post-build validation requires a trusted RFC3161 timestamp, the canonical `WINDOWS_SIGNING_EXPECTED_SUBJECT` and/or `WINDOWS_SIGNING_EXPECTED_THUMBPRINT` identity, and the same signer on the NSIS installer and installed channel executable. Those four `WINDOWS_*` names are the complete Windows signing-secret contract; the existing generic `CSC_*` secrets remain macOS-only. +- Windows-only wrappers for the bundled `ade` CLI ship in `apps/desktop/scripts/`: `ade-cli-windows-wrapper.cmd` (channel-aware launcher), `ade-cli-install-path.cmd` (idempotent PATH install helper), and `windows-install-setup.ps1` (post-install CLI/service setup). The platform-agnostic `.sh` wrapper covers macOS/Linux. +- The Windows installer is an assisted, per-user, non-elevating NSIS install. It bundles the prebuilt `cr-sqlite` native binary from `apps/desktop/vendor/crsqlite/win32-x64/`, a Windows node-pty ConPTY worker, and all Darwin/Linux remote-runtime sidecars. `validate-win-artifacts.mjs` asserts each one is unpacked and executes a real CRR change through the installed DLL. The custom install step repairs the channel-specific CLI shim, user PATH, and brain startup registration; uninstall removes only registry/PATH/protocol/startup state owned by that exact installation. +- Electron-builder generates `resources/app-update.yml` from the GitHub publish configuration. The source default remains the upstream `arul28/ADE`; CI sets `ADE_RELEASE_REPOSITORY=${{ github.repository }}` so fork package validation proves the installed updater authority matches the repository that built it without changing upstream release behavior. +- `release-core.yml` runs the signed Windows job only when repository variable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`. If enabled, missing signing credentials or pinned publisher identity fail the Windows job and block publication; if disabled, the skipped Windows job does not block the macOS release. Windows assets are added to the draft only when all signed-build, public-release, and installed-update-proof gates are enabled. Keep publication gates off until clean-host install checks and the mandatory two-unpublished-version N-to-N+1 signed update proof pass; that proof includes timestamp/signature validation, tamper rejection, desktop relaunch, brain recovery, and data preservation. - Ongoing Windows integration lane (rebase with `main`, smoke tests, backlog): `docs/development/windows-port-lane.md`. Post-packaging hardening (`apps/desktop/scripts/`): diff --git a/docs/development/windows-port-lane.md b/docs/development/windows-port-lane.md index 2a75f116d..1753b81a1 100644 --- a/docs/development/windows-port-lane.md +++ b/docs/development/windows-port-lane.md @@ -21,13 +21,18 @@ These are the foundations that should stay merged from this lane (see also `docs | Area | What shipped | | --- | --- | -| **CLI ↔ desktop IPC** | Windows named-pipe path next to the Unix socket model (`adeRuntimeIpc.*`). | +| **CLI ↔ desktop IPC** | Runtime and desktop-bridge named pipes are hashed from canonical `ADE_HOME`, channel/service identity, and current-user identity (SID when available). Stable/Beta/Alpha and separate Windows accounts do not share endpoints. Servers explicitly use intended-user-only Node pipe flags; effective second-account denial remains a clean-VM gate. | +| **Background brain** | `installWindows.ts` owns a per-user, per-channel `HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run` entry. A BOM-marked PowerShell supervisor restores the resolved brain environment, verifies process identity and runtime readiness on the expected named pipe, restarts crashes with bounded backoff, and records actionable diagnostics. Scheduled Tasks are legacy cleanup only. | | **Child processes** | `processExecution` — `cmd`/`bat` via `ComSpec`, `windowsVerbatimArguments`, `taskkill` for trees. Both `resolveWindowsCmdInvocation` (argv form) and `resolveWindowsCmdLineInvocation` (pre-built command-string form) wrap a single outer `cmd.exe /d /s /c "…"` so embedded `&&` chains don't break out of quoting. Long-running children that previously called `child.kill("SIGKILL")` (git, automation runs) now route through `terminateProcessTree` so taskkill cleans up Windows process groups. | | **PATH for CLIs** | `augmentProcessPathWithShellAndKnownCliDirs` has an explicit `win32` path (no POSIX `sh -ic`). | -| **PTY** | `ptyService` picks `powershell.exe` / `cmd.exe` on Windows. | +| **PTY/providers** | `ptyService` supports no-profile Windows PowerShell 5.1/7, cmd, and Git Bash. Provider launch/resume materializes structured command, argv, environment, and recovery metadata on the lane-owning runtime, with ConPTY resize, cancellation, and descendant cleanup. | +| **App Control** | Recognized direct Electron and package-script launches become structured Windows command/argv/env/cwd descriptors. Shell fallbacks emit PowerShell/cmd syntax instead of POSIX environment syntax; macOS/Linux behavior remains unchanged. | | **Renderer paths** | `pathUtils` — drive letters, `\`, UNC, comparison helpers for workspace UI. | -| **Native** | `vendor/crsqlite/win32-x64`, `node-pty` Windows prebuild, packaged runtime hooks. | -| **Installers** | `ade-cli-windows-wrapper.cmd`, `ade-cli-install-path.cmd` (now updates the user `Environment\Path` registry value via PowerShell and broadcasts `WM_SETTINGCHANGE`), `npm run dist:win`, `release-core.yml` `build-win-release` job (consumes optional `WINDOWS_CSC_LINK` / `WINDOWS_CSC_KEY_PASSWORD` to sign with electron-builder + DigiCert RFC3161 timestamp server when configured; otherwise emits an unsigned installer). | +| **Native + sync** | `vendor/crsqlite/win32-x64`, `node-pty` Windows prebuild, packaged runtime hooks. The required `win-unpacked` smoke loads `crsqlite.dll`, marks a table CRR, writes a row, and requires a `crsql_changes` record. Runtime capability is exposed as `crdtSyncAvailable`; Connections blocks pairing and shows reinstall/restart guidance if unavailable. | +| **Desktop UX** | Windows uses a hidden title bar with native window overlay/caption controls and an explicit AppUserModelID. iOS Simulator and macOS Attention Notch controls are hidden; persisted iOS sidebar state falls back to Git. App Control, built-in Browser, and proof ingestion remain available. Visible local-machine/Finder/Command-key copy is platform-neutral or platform-aware. | +| **Installers** | The assisted NSIS installer is explicitly per-user and non-elevating. Its custom install step repairs the channel-aware CLI shim, current-user `PATH`, and brain startup registration; uninstall removes only the terminal shim, PATH/protocol/association/startup state owned by that installation. Stable/Beta/Alpha use distinct executable, app, and shim names. Windows packages carry all Darwin/Linux remote-runtime sidecars. Electron-builder owns `app-update.yml`; CI binds it to `${{ github.repository }}` and package smoke verifies the authority. | +| **Standalone brain** | Releases build `ade-win32-x64.exe` plus a native dependency archive and checksum them with all other runtime artifacts. `install.ps1` stages and verifies both, installs the current-user PATH/service, and rolls back on failure. `ade brain start/status/doctor/update` support Windows; self-update stops the running executable before replacement and restores the previous runtime/service on failure. | +| **CI/release** | `ci.yml` has a required `windows-latest` package job that builds and smokes an unsigned preview, including fresh install, repair, reinstall, PATH/startup/deep-link/file-association ownership, and uninstall. `release-core.yml` enables the signed job only with `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`; the Windows signing-secret contract is exactly `WINDOWS_CSC_LINK`, `WINDOWS_CSC_KEY_PASSWORD`, `WINDOWS_SIGNING_EXPECTED_SUBJECT`, and `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`, with a trusted RFC3161 timestamp and one signer required for installer + app. While public Windows publication is disabled, a failed or skipped signed Windows test build cannot block the existing macOS release. Public Windows assets additionally require both `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` and `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1`; the latter attests to the mandatory clean-host, two-unpublished-version signed N-to-N+1 updater proof. | | **Sync / Tailscale** | `resolveTailscaleCliPath` (shared): macOS bundle, Windows `Program Files`\\Tailscale, then `PATH`. | ## Mainline feature areas to smoke-test on Windows after each rebase @@ -45,17 +50,53 @@ Recent `main` work that is **not** inherently macOS-only but can surface path/sh ## Intentionally not Windows-complete (product reality) - **Local computer use** (screenshot, video, Apple GUI automation) remains **macOS-first**; other platforms are `blocked_by_capability` by design — do not block the Windows port on this. -- **Releases** — `release-core.yml` builds a Windows `exe`; Authenticode signing is supported when credentials are configured, but unsigned Windows release artifacts are allowed for now. SmartScreen reputation is still a release-engineering concern, not only app code. +- **Windows OS control** — App Control/CDP and proof ingestion work; native + Windows Graphics Capture/UI Automation is not implemented. +- **iOS Simulator / Attention Notch** — hidden on Windows by capability. These + remain macOS-only product surfaces. +- **Releases** — pull-request CI may publish an unsigned Windows preview artifact for internal testing. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, `release-core.yml` fails the Windows job unless the `exe` and packaged app are Authenticode signed, timestamped, share one certificate, and match `WINDOWS_SIGNING_EXPECTED_SUBJECT` or `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`. That test job cannot block macOS publication while public Windows publication is disabled. SmartScreen reputation remains a release-engineering concern, not only app code. +- **Windows as an SSH-bootstrap target** — the standalone Windows brain is + installable and updateable, but desktop remote bootstrap still detects and + uploads only supported macOS/Linux targets. A Windows client can bootstrap + those targets and reports actionable OpenSSH Client prerequisite diagnostics. - **Docs in `AGENTS.md`** still emphasize macOS Codex/Computer Use; Windows developers should use this file + `docs/ARCHITECTURE.md` for WSL/VM dev notes if applicable. ## Engineering backlog (complete the “parity” bar) Do these to move from “runs on Windows” to “first-class for Windows users”: -1. **Rebase hygiene** — whenever `main` adds desktop surfaces, re-run the smoke list above; add Vitest cases under `pathUtils` / `processExecution` if new path or spawn patterns appear. -2. **Non-default Tailscale installs** — if the binary is not under standard `Program Files` locations and not on `PATH`, set `ADE_TAILSCALE_CLI` to the full `tailscale.exe` path. -3. **Code signing reputation** — wire Authenticode credentials in CI when ready and monitor SmartScreen reputation; keep `latest.yml` + `exe.blockmap` flow as today. -4. **Download + updates** — ensure the web download page and auto-update story include Windows; verify `autoUpdateService` for Windows channel if applicable. +1. **Clean standard-user hosts** — install/uninstall/reinstall on Windows 10 + 22H2 and Windows 11 x64 with no global Node; verify first launch, Start Menu + relaunch, repair, `ade brain start/status/doctor/update`, logoff/logon brain + recovery, deep links/file associations, and no orphaned legacy task/launcher. +2. **Channel/user isolation** — run Stable and Beta side by side and verify + separate tasks, ADE homes, runtime/desktop-bridge pipes, and project state; + repeat with a second Windows account. +3. **Provider/PTY matrix** — fresh launch and resume for Claude, Codex, Cursor, + Droid, and OpenCode in PowerShell and cmd, including Unicode and paths or + prompts containing spaces, quotes, `$`, `%`, `&`, and backticks. Exercise + resize, Ctrl+C, cancellation, and child-tree cleanup. +4. **Sync and firewall** — pair a physical iPhone, prove bidirectional CRR + changes, verify Windows Defender Firewall behavior on LAN, then exercise + Tailscale/Relay fallback. For a non-default Tailscale install, set + `ADE_TAILSCALE_CLI` explicitly. +5. **Remote/runtime and UI** — bootstrap supported macOS/Linux remote runtimes + from the Windows package; exercise lanes/git/files/browser/App Control, + deep links, file associations, DPI 100–200%, Snap Layouts, multiple + monitors, high contrast, and keyboard navigation. +6. **Signed installer** — verify the installer and installed app use the approved + publisher, then install, relaunch, log off/on, uninstall, and reinstall. + Provision signing credentials and monitor SmartScreen reputation. Before + publication, use two unpublished signed versions to prove N to N+1 update, + timestamp/signature validation, tamper rejection, desktop relaunch and brain + recovery, and data preservation. +7. **Public gates** — enable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1` to produce + signed test builds. Keep `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0` and the website + flag disabled until the signed installer and mandatory two-version update + proof pass. Only then may a maintainer enable public release and website flags. + +The complete maintainer procedure is +[Windows signed release and publication](../playbooks/windows-signed-release.md). ## Suggested validation commands (from repo root) diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index a82a59f94..49723d7a7 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -533,6 +533,10 @@ run a local repair against data owned by the remote machine. See back to bounded SSH chunk uploads / OpenSSH. Without a bundled binary, ADE probes alternate channel homes for a compatible installed runtime and reports the selected fallback as a compatibility warning. + Windows clients require the built-in OpenSSH Client for the bounded fallback + upload path. If `ssh.exe` is unavailable, ADE reports the missing Windows + Optional Feature and the `Add-WindowsCapability` repair command instead of a + raw process-spawn error. 7. Once SSH RPC succeeds, ADE asks that exact runtime to authorize this desktop as a paired DPoP device using a bounded JSON request on stdin. The resulting secret, private key, host identity, and endpoints are stored locally, and @@ -612,12 +616,16 @@ npm --prefix apps/ade-cli run build:static -- --target --out-dir ../des ## Standalone runtime install -For headless macOS / Linux machines that can run an SSH server but have no desktop, the runtime can be installed directly from a release. Release publishing includes `install.sh`, `SHA256SUMS`, the `ade-` binaries, and the matching native dependency archives for darwin/linux arm64/x64. SSH-reachable machines can still skip the standalone installer because desktop bootstrap uploads bundled runtime artifacts on first connect. +For headless macOS / Linux machines that can run an SSH server but have no desktop, the runtime can be installed directly from a release. Windows x64 machines can install the same standalone brain locally. Release publishing includes `install.sh`, `install.ps1`, `SHA256SUMS`, the `ade-` binaries (with `.exe` for Windows), and matching native dependency archives. SSH-reachable macOS/Linux machines can still skip the standalone installer because desktop bootstrap uploads bundled runtime artifacts on first connect; Windows remains a local standalone target, not an SSH-bootstrap target. ```bash curl -fsSL https://github.com/arul28/ADE/releases/latest/download/install.sh | sh ``` +```powershell +irm https://github.com/arul28/ADE/releases/latest/download/install.ps1 | iex +``` + `install.sh` (lives at `apps/ade-cli/scripts/install-runtime.sh`): - detects platform / arch with `uname -sm`, From cd5de88e3797c84254d8980a218416e0cc56cbbc Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 22:19:10 -0400 Subject: [PATCH 05/12] fix(windows): harden standalone release and brain updates Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- .github/workflows/release-core.yml | 84 ++++++- apps/ade-cli/README.md | 4 +- apps/ade-cli/scripts/install-runtime.ps1 | 9 + apps/ade-cli/scripts/sign-windows-runtime.ps1 | 97 +++++++++ apps/ade-cli/src/commands/brainUpdate.test.ts | 205 ++++++++++++++++++ apps/ade-cli/src/commands/brainUpdate.ts | 118 ++++++++-- .../scripts/windows-release-contract.test.mjs | 56 ++++- docs/ARCHITECTURE.md | 2 +- docs/development/windows-port-lane.md | 11 +- 9 files changed, 551 insertions(+), 35 deletions(-) create mode 100644 apps/ade-cli/scripts/sign-windows-runtime.ps1 diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index f01e43b35..0913e83dd 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -413,6 +413,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_SIGNED_BUILD_ENABLED == '1' }} + shell: pwsh + env: + WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} + WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + WINDOWS_SIGNING_EXPECTED_SUBJECT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_SUBJECT }} + WINDOWS_SIGNING_EXPECTED_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_EXPECTED_THUMBPRINT }} + 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: @@ -485,6 +497,19 @@ jobs: exit 1 fi + - name: Assemble signed Windows standalone proof bundle + if: ${{ matrix.target == 'win32-x64' && vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1' }} + 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: @@ -492,6 +517,8 @@ jobs: path: | 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 @@ -564,15 +591,39 @@ jobs: merge-multiple: true - name: Add standalone runtime installer + env: + BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} + PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} + WINDOWS_UPDATE_PROOF_APPROVED: ${{ vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED }} run: | cp apps/ade-cli/scripts/install-runtime.sh release-assets/runtime/install.sh chmod 755 release-assets/runtime/install.sh - cp apps/ade-cli/scripts/install-runtime.ps1 release-assets/runtime/install.ps1 + if [ "$BUILD_WINDOWS" = "1" ] && [ "$PUBLISH_WINDOWS" = "1" ] && [ "$WINDOWS_UPDATE_PROOF_APPROVED" = "1" ]; then + cp apps/ade-cli/scripts/install-runtime.ps1 release-assets/runtime/install.ps1 + fi - name: Generate standalone runtime checksums + env: + BUILD_WINDOWS: ${{ vars.ADE_WINDOWS_SIGNED_BUILD_ENABLED }} + PUBLISH_WINDOWS: ${{ vars.ADE_WINDOWS_PUBLIC_RELEASE_ENABLED }} + WINDOWS_UPDATE_PROOF_APPROVED: ${{ vars.ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED }} run: | set -euo pipefail - (cd release-assets/runtime && sha256sum install.sh install.ps1 ade-* | LC_ALL=C sort -k2 > SHA256SUMS) + runtime_assets=( + install.sh + 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 + ) + if [ "$BUILD_WINDOWS" = "1" ] && [ "$PUBLISH_WINDOWS" = "1" ] && [ "$WINDOWS_UPDATE_PROOF_APPROVED" = "1" ]; then + runtime_assets+=(install.ps1 ade-win32-x64.exe ade-win32-x64.native.tar.gz) + fi + (cd release-assets/runtime && sha256sum "${runtime_assets[@]}" | LC_ALL=C sort -k2 > SHA256SUMS) - name: Validate publish asset manifest run: | @@ -610,13 +661,11 @@ jobs: echo "::error::Standalone runtime installer is not executable." exit 1 fi - require_file 'release-assets/runtime/install.ps1' 'standalone Windows runtime installer' 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 win32-x64; do + for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do binary="release-assets/runtime/ade-$target" - if [ "$target" = "win32-x64" ]; then binary="$binary.exe"; fi 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" @@ -641,6 +690,12 @@ jobs: test -s "${installers[0]}" test -s "${blockmaps[0]}" test -s release-assets/win/latest.yml + 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 + grep -Eq '[[:space:]]install\.ps1$' release-assets/runtime/SHA256SUMS + grep -Eq '[[:space:]]ade-win32-x64\.exe$' release-assets/runtime/SHA256SUMS + grep -Eq '[[:space:]]ade-win32-x64\.native\.tar\.gz$' release-assets/runtime/SHA256SUMS - name: Create or update draft GitHub release env: GH_TOKEN: ${{ github.token }} @@ -661,9 +716,9 @@ jobs: release-assets/mac/*.zip release-assets/mac/latest-mac.yml release-assets/runtime/install.sh - release-assets/runtime/install.ps1 release-assets/runtime/SHA256SUMS - release-assets/runtime/ade-* + release-assets/runtime/ade-darwin-* + release-assets/runtime/ade-linux-* ) # This repository variable stays disabled until the signed installer @@ -673,6 +728,9 @@ jobs: release-assets/win/*.exe release-assets/win/*.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 ) fi @@ -687,6 +745,18 @@ jobs: echo "::error::Release $TAG_NAME is already public. Refusing to overwrite published assets." exit 1 fi + if [ "$BUILD_WINDOWS" != "1" ] || [ "$PUBLISH_WINDOWS" != "1" ] || [ "$WINDOWS_UPDATE_PROOF_APPROVED" != "1" ]; then + mapfile -t existing_assets < <( + gh release view "$TAG_NAME" --repo "$GH_REPO" --json assets --jq '.assets[].name' + ) + for asset in "${existing_assets[@]}"; do + case "$asset" in + install.ps1|ade-win32-x64.exe|ade-win32-x64.native.tar.gz|ADE*-win-x64.exe|ADE*-win-x64.exe.blockmap|latest.yml) + gh release delete-asset "$TAG_NAME" "$asset" --repo "$GH_REPO" --yes + ;; + esac + done + fi gh release upload "$TAG_NAME" "${files[@]}" --repo "$GH_REPO" --clobber else gh release create "$TAG_NAME" "${files[@]}" \ diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index bcbaae946..a2e5b466f 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -65,6 +65,8 @@ 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. + 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: @@ -170,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` (`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 and restores the previous executable, native tree, and service if promotion fails. 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/install-runtime.ps1 b/apps/ade-cli/scripts/install-runtime.ps1 index 5bd2aa717..74c6d75ca 100644 --- a/apps/ade-cli/scripts/install-runtime.ps1 +++ b/apps/ade-cli/scripts/install-runtime.ps1 @@ -2,6 +2,7 @@ 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, @@ -23,6 +24,14 @@ function Resolve-AssetUrl([string]$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" 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..8900fa22f --- /dev/null +++ b/apps/ade-cli/scripts/sign-windows-runtime.ps1 @@ -0,0 +1,97 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BinaryPath, + [string]$TimestampServer = "http://timestamp.digicert.com" +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Fail([string]$Message) { + throw "ADE Windows runtime signing: $Message" +} + +function Normalize-Thumbprint([string]$Value) { + return ($Value -replace '\s+', '').ToUpperInvariant() +} + +$resolvedBinary = [IO.Path]::GetFullPath($BinaryPath) +if (-not (Test-Path -LiteralPath $resolvedBinary -PathType Leaf)) { + Fail "runtime binary is missing: $resolvedBinary" +} + +$certificateSource = [string]$env:WINDOWS_CSC_LINK +$certificatePassword = [string]$env:WINDOWS_CSC_KEY_PASSWORD +$expectedSubject = ([string]$env:WINDOWS_SIGNING_EXPECTED_SUBJECT).Trim() +$expectedThumbprint = Normalize-Thumbprint ([string]$env:WINDOWS_SIGNING_EXPECTED_THUMBPRINT) +if ([string]::IsNullOrWhiteSpace($certificateSource) -or [string]::IsNullOrWhiteSpace($certificatePassword)) { + Fail "WINDOWS_CSC_LINK and WINDOWS_CSC_KEY_PASSWORD are required" +} +if ([string]::IsNullOrWhiteSpace($expectedSubject) -and [string]::IsNullOrWhiteSpace($expectedThumbprint)) { + Fail "WINDOWS_SIGNING_EXPECTED_SUBJECT or WINDOWS_SIGNING_EXPECTED_THUMBPRINT is required" +} + +$certificatePath = Join-Path ([IO.Path]::GetTempPath()) ("ade-runtime-signing-" + [Guid]::NewGuid().ToString("N") + ".pfx") +try { + $trimmedSource = $certificateSource.Trim() + if (Test-Path -LiteralPath $trimmedSource -PathType Leaf) { + Copy-Item -LiteralPath $trimmedSource -Destination $certificatePath + } elseif ($trimmedSource.StartsWith("file://", [StringComparison]::OrdinalIgnoreCase)) { + $sourceUri = [Uri]$trimmedSource + Copy-Item -LiteralPath $sourceUri.LocalPath -Destination $certificatePath + } elseif ($trimmedSource.StartsWith("https://", [StringComparison]::OrdinalIgnoreCase)) { + Invoke-WebRequest -UseBasicParsing -Uri $trimmedSource -OutFile $certificatePath + } elseif ($trimmedSource.StartsWith("http://", [StringComparison]::OrdinalIgnoreCase)) { + Fail "WINDOWS_CSC_LINK must use HTTPS, a local path, or an encoded certificate payload" + } else { + try { + [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($trimmedSource)) + } catch { + Fail "WINDOWS_CSC_LINK is not a valid certificate path, HTTPS URL, or Base64 payload" + } + } + + $flags = [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + $certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + [IO.File]::ReadAllBytes($certificatePath), + $certificatePassword, + $flags + ) + try { + if (-not $certificate.HasPrivateKey) { + Fail "the configured certificate has no private key" + } + + $signingResult = Set-AuthenticodeSignature ` + -LiteralPath $resolvedBinary ` + -Certificate $certificate ` + -HashAlgorithm SHA256 ` + -TimestampServer $TimestampServer + if ($signingResult.Status -ne [Management.Automation.SignatureStatus]::Valid) { + Fail "runtime signing failed with status $($signingResult.Status)" + } + + $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() + $actualThumbprint = Normalize-Thumbprint ([string]$signature.SignerCertificate.Thumbprint) + if ($expectedSubject -and -not [string]::Equals($actualSubject, $expectedSubject, [StringComparison]::OrdinalIgnoreCase)) { + Fail "signed runtime publisher subject does not match WINDOWS_SIGNING_EXPECTED_SUBJECT" + } + if ($expectedThumbprint -and -not [string]::Equals($actualThumbprint, $expectedThumbprint, [StringComparison]::Ordinal)) { + Fail "signed runtime certificate does not match WINDOWS_SIGNING_EXPECTED_THUMBPRINT" + } + } finally { + $certificate.Dispose() + } + + Write-Output "Signed and validated the standalone Windows runtime." +} finally { + Remove-Item -LiteralPath $certificatePath -Force -ErrorAction SilentlyContinue +} diff --git a/apps/ade-cli/src/commands/brainUpdate.test.ts b/apps/ade-cli/src/commands/brainUpdate.test.ts index 7dbc38e51..86e1f4a0e 100644 --- a/apps/ade-cli/src/commands/brainUpdate.test.ts +++ b/apps/ade-cli/src/commands/brainUpdate.test.ts @@ -1,4 +1,5 @@ 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"; @@ -25,6 +26,26 @@ function checksumFileFor(target: string, binaryContent: string, archiveContent = ].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); @@ -32,6 +53,7 @@ function tempRoot(): string { } afterEach(() => { + vi.restoreAllMocks(); for (const root of tempRoots.splice(0)) { fs.rmSync(root, { recursive: true, force: true }); } @@ -329,6 +351,92 @@ describe("brain update command", () => { 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"); @@ -387,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 a467d4f4d..ba0ab1428 100644 --- a/apps/ade-cli/src/commands/brainUpdate.ts +++ b/apps/ade-cli/src/commands/brainUpdate.ts @@ -84,6 +84,7 @@ export type BrainUpdateDeps = { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; arch?: string; + execPath?: string; currentVersion?: string; now?: () => Date; tmpDir?: () => Promise; @@ -560,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; } @@ -570,12 +580,53 @@ 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, @@ -603,14 +654,24 @@ 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(process.env, manifest.runtimeTargetDir), + ...runtimeSidecarEnv(baseEnv, manifest.runtimeTargetDir), ADE_HOME: manifest.adeHome, }); const restartRolledBackWindowsService = (): string | null => { @@ -674,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 () => { @@ -708,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", @@ -725,7 +810,7 @@ async function applyStagedBrainUpdate( target: manifest.target, binaryPath: manifest.binaryPath, runtimePath: manifest.runtimeTargetDir, - message: "ADE brain runtime updated. Service restart was skipped.", + message, }; } @@ -765,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", @@ -777,7 +863,7 @@ async function applyStagedBrainUpdate( target: manifest.target, binaryPath: manifest.binaryPath, runtimePath: manifest.runtimeTargetDir, - message: "ADE brain updated and service restart requested.", + message, }; } catch (error) { const baseMessage = error instanceof Error ? error.message : String(error); diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index 04bdb9545..a03ac6e48 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -48,6 +48,10 @@ 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"), @@ -194,19 +198,59 @@ test("Beta validation selects only Beta artifacts when Stable files are retained assert.match(winArtifactValidator, /windowsInstallerPattern\(packageIdentity\)/); }); -test("standalone releases include a checksummed Windows brain and PowerShell installer", () => { +test("standalone Windows runtime signing uses only canonical credentials and validates publisher identity", () => { const runtimeBuild = jobBlock(releaseWorkflow, "build-runtime-binaries", "publish-release"); + 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_SIGNED_BUILD_ENABLED == '1'/); + assert.match(windowsSignStep, /WINDOWS_CSC_LINK: \$\{\{ secrets\.WINDOWS_CSC_LINK \}\}/); + assert.match(windowsSignStep, /WINDOWS_CSC_KEY_PASSWORD: \$\{\{ secrets\.WINDOWS_CSC_KEY_PASSWORD \}\}/); + assert.match(windowsSignStep, /WINDOWS_SIGNING_EXPECTED_SUBJECT/); + assert.match(windowsSignStep, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT/); + assert.doesNotMatch(windowsSignStep, /(?:^|\s)(?:WIN_CSC_LINK|WIN_CSC_KEY_PASSWORD|CSC_LINK|CSC_KEY_PASSWORD):/m); + assert.match(windowsSignStep, /sign-windows-runtime\.ps1/); + assert.match(windowsRuntimeSigner, /Set-AuthenticodeSignature/); + assert.match(windowsRuntimeSigner, /Get-AuthenticodeSignature/); + assert.match(windowsRuntimeSigner, /TimeStamperCertificate/); + assert.match(windowsRuntimeSigner, /WINDOWS_SIGNING_EXPECTED_SUBJECT/); + assert.match(windowsRuntimeSigner, /WINDOWS_SIGNING_EXPECTED_THUMBPRINT/); + assert.match(windowsRuntimeSigner, /X509KeyStorageFlags\]::EphemeralKeySet/); + assert.doesNotMatch(windowsRuntimeSigner, /Write-Output.*(?:certificateSource|certificatePassword|expectedSubject|expectedThumbprint)/); +}); + +test("standalone Windows release assets remain behind every publication and proof gate", () => { const publish = jobBlock(releaseWorkflow, "publish-release", null); + const runtimeBuild = jobBlock(releaseWorkflow, "build-runtime-binaries", "publish-release"); const installer = fs.readFileSync( path.join(repoRoot, "apps", "ade-cli", "scripts", "install-runtime.ps1"), "utf8", ); - assert.match(runtimeBuild, /target: win32-x64[\s\S]*os: windows-latest[\s\S]*binary: ade-win32-x64\.exe/); - assert.match(publish, /install-runtime\.ps1 release-assets\/runtime\/install\.ps1/); - assert.match(publish, /sha256sum install\.sh install\.ps1 ade-\*/); - assert.match(publish, /darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-x64/); - assert.match(publish, /if \[ "\$target" = "win32-x64" \]; then binary="\$binary\.exe"/); + const releaseFiles = publish.slice(publish.indexOf("files=("), publish.indexOf("if [ \"$BUILD_WINDOWS\"", publish.indexOf("files=("))); + assert.doesNotMatch(releaseFiles, /install\.ps1|ade-win32-x64/); + assert.match(publish, /runtime_assets\+=\(install\.ps1 ade-win32-x64\.exe ade-win32-x64\.native\.tar\.gz\)/); + 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/); + assert.match(publish, /release-assets\/runtime\/install\.ps1[\s\S]*release-assets\/runtime\/ade-win32-x64\.exe[\s\S]*release-assets\/runtime\/ade-win32-x64\.native\.tar\.gz/); + assert.ok( + (publish.match(/\[ "\$BUILD_WINDOWS" = "1" \] && \[ "\$PUBLISH_WINDOWS" = "1" \] && \[ "\$WINDOWS_UPDATE_PROOF_APPROVED" = "1" \]/g) ?? []).length >= 3, + "copying, checksumming, and publishing standalone Windows assets must all require every gate", + ); + assert.match(publish, /install\.ps1\|ade-win32-x64\.exe\|ade-win32-x64\.native\.tar\.gz/); + assert.match(publish, /gh release delete-asset "\$TAG_NAME" "\$asset" --repo "\$GH_REPO" --yes/); + assert.match(publish, /"\$BUILD_WINDOWS" != "1"[\s\S]*"\$PUBLISH_WINDOWS" != "1"[\s\S]*"\$WINDOWS_UPDATE_PROOF_APPROVED" != "1"/); + assert.match(runtimeBuild, /name: Assemble signed Windows standalone proof bundle/); + assert.match(runtimeBuild, /matrix\.target == 'win32-x64' && vars\.ADE_WINDOWS_SIGNED_BUILD_ENABLED == '1'/); + 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/); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 569898c86..f8a907804 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1501,7 +1501,7 @@ Windows: - Windows-only wrappers for the bundled `ade` CLI ship in `apps/desktop/scripts/`: `ade-cli-windows-wrapper.cmd` (channel-aware launcher), `ade-cli-install-path.cmd` (idempotent PATH install helper), and `windows-install-setup.ps1` (post-install CLI/service setup). The platform-agnostic `.sh` wrapper covers macOS/Linux. - The Windows installer is an assisted, per-user, non-elevating NSIS install. It bundles the prebuilt `cr-sqlite` native binary from `apps/desktop/vendor/crsqlite/win32-x64/`, a Windows node-pty ConPTY worker, and all Darwin/Linux remote-runtime sidecars. `validate-win-artifacts.mjs` asserts each one is unpacked and executes a real CRR change through the installed DLL. The custom install step repairs the channel-specific CLI shim, user PATH, and brain startup registration; uninstall removes only registry/PATH/protocol/startup state owned by that exact installation. - Electron-builder generates `resources/app-update.yml` from the GitHub publish configuration. The source default remains the upstream `arul28/ADE`; CI sets `ADE_RELEASE_REPOSITORY=${{ github.repository }}` so fork package validation proves the installed updater authority matches the repository that built it without changing upstream release behavior. -- `release-core.yml` runs the signed Windows job only when repository variable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`. If enabled, missing signing credentials or pinned publisher identity fail the Windows job and block publication; if disabled, the skipped Windows job does not block the macOS release. Windows assets are added to the draft only when all signed-build, public-release, and installed-update-proof gates are enabled. Keep publication gates off until clean-host install checks and the mandatory two-unpublished-version N-to-N+1 signed update proof pass; that proof includes timestamp/signature validation, tamper rejection, desktop relaunch, brain recovery, and data preservation. +- `release-core.yml` runs the signed Windows job only when repository variable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`. If enabled, missing signing credentials or pinned publisher identity fail both the desktop and standalone-runtime signing paths and block publication; if disabled, the skipped Windows job does not block the macOS release. The installer, updater metadata, `install.ps1`, signed `ade-win32-x64.exe`, and its native archive are added to the draft only when both `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` and `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1`. Keep both publication gates off until clean-host install checks and the mandatory two-unpublished-version N-to-N+1 signed update proof pass; that proof includes timestamp/signature validation, tamper rejection, desktop relaunch, brain recovery, and data preservation. - Ongoing Windows integration lane (rebase with `main`, smoke tests, backlog): `docs/development/windows-port-lane.md`. Post-packaging hardening (`apps/desktop/scripts/`): diff --git a/docs/development/windows-port-lane.md b/docs/development/windows-port-lane.md index 1753b81a1..c108bce5e 100644 --- a/docs/development/windows-port-lane.md +++ b/docs/development/windows-port-lane.md @@ -32,7 +32,7 @@ These are the foundations that should stay merged from this lane (see also `docs | **Desktop UX** | Windows uses a hidden title bar with native window overlay/caption controls and an explicit AppUserModelID. iOS Simulator and macOS Attention Notch controls are hidden; persisted iOS sidebar state falls back to Git. App Control, built-in Browser, and proof ingestion remain available. Visible local-machine/Finder/Command-key copy is platform-neutral or platform-aware. | | **Installers** | The assisted NSIS installer is explicitly per-user and non-elevating. Its custom install step repairs the channel-aware CLI shim, current-user `PATH`, and brain startup registration; uninstall removes only the terminal shim, PATH/protocol/association/startup state owned by that installation. Stable/Beta/Alpha use distinct executable, app, and shim names. Windows packages carry all Darwin/Linux remote-runtime sidecars. Electron-builder owns `app-update.yml`; CI binds it to `${{ github.repository }}` and package smoke verifies the authority. | | **Standalone brain** | Releases build `ade-win32-x64.exe` plus a native dependency archive and checksum them with all other runtime artifacts. `install.ps1` stages and verifies both, installs the current-user PATH/service, and rolls back on failure. `ade brain start/status/doctor/update` support Windows; self-update stops the running executable before replacement and restores the previous runtime/service on failure. | -| **CI/release** | `ci.yml` has a required `windows-latest` package job that builds and smokes an unsigned preview, including fresh install, repair, reinstall, PATH/startup/deep-link/file-association ownership, and uninstall. `release-core.yml` enables the signed job only with `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`; the Windows signing-secret contract is exactly `WINDOWS_CSC_LINK`, `WINDOWS_CSC_KEY_PASSWORD`, `WINDOWS_SIGNING_EXPECTED_SUBJECT`, and `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`, with a trusted RFC3161 timestamp and one signer required for installer + app. While public Windows publication is disabled, a failed or skipped signed Windows test build cannot block the existing macOS release. Public Windows assets additionally require both `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` and `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1`; the latter attests to the mandatory clean-host, two-unpublished-version signed N-to-N+1 updater proof. | +| **CI/release** | `ci.yml` has a required `windows-latest` package job that builds and smokes an unsigned preview, including fresh install, repair, reinstall, PATH/startup/deep-link/file-association ownership, and uninstall. `release-core.yml` enables the signed job only with `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`; the Windows signing-secret contract is exactly `WINDOWS_CSC_LINK`, `WINDOWS_CSC_KEY_PASSWORD`, `WINDOWS_SIGNING_EXPECTED_SUBJECT`, and `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`, with a trusted RFC3161 timestamp and the approved identity required for the installer, installed app, and standalone runtime (thumbprint equality is enforced whenever a thumbprint is configured). The non-publishing run retains a checksum-covered standalone proof bundle for offline clean-host installation. While public Windows publication is disabled, a failed or skipped signed Windows test build cannot block the existing macOS release. Public Windows assets additionally require both `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` and `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1`; the latter attests to the mandatory clean-host, two-unpublished-version signed N-to-N+1 updater proof. | | **Sync / Tailscale** | `resolveTailscaleCliPath` (shared): macOS bundle, Windows `Program Files`\\Tailscale, then `PATH`. | ## Mainline feature areas to smoke-test on Windows after each rebase @@ -54,7 +54,7 @@ Recent `main` work that is **not** inherently macOS-only but can surface path/sh Windows Graphics Capture/UI Automation is not implemented. - **iOS Simulator / Attention Notch** — hidden on Windows by capability. These remain macOS-only product surfaces. -- **Releases** — pull-request CI may publish an unsigned Windows preview artifact for internal testing. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, `release-core.yml` fails the Windows job unless the `exe` and packaged app are Authenticode signed, timestamped, share one certificate, and match `WINDOWS_SIGNING_EXPECTED_SUBJECT` or `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`. That test job cannot block macOS publication while public Windows publication is disabled. SmartScreen reputation remains a release-engineering concern, not only app code. +- **Releases** — pull-request CI may publish an unsigned Windows preview artifact for internal testing. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, `release-core.yml` fails the Windows jobs unless the installer, packaged app, and standalone runtime are Authenticode signed, timestamped, and match `WINDOWS_SIGNING_EXPECTED_SUBJECT` or `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`; the installer and packaged app must also share one certificate. That test job cannot block macOS publication while public Windows publication is disabled. SmartScreen reputation remains a release-engineering concern, not only app code. - **Windows as an SSH-bootstrap target** — the standalone Windows brain is installable and updateable, but desktop remote bootstrap still detects and uploads only supported macOS/Linux targets. A Windows client can bootstrap @@ -92,8 +92,11 @@ Do these to move from “runs on Windows” to “first-class for Windows users recovery, and data preservation. 7. **Public gates** — enable `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1` to produce signed test builds. Keep `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=0` and the website - flag disabled until the signed installer and mandatory two-version update - proof pass. Only then may a maintainer enable public release and website flags. + flag disabled until the signed installer, signed standalone runtime, and + mandatory two-version update proof pass. The PowerShell installer, Windows + runtime executable, and native archive remain outside release drafts until + the public-release and installed-update-proof gates are both enabled. Only + then may a maintainer enable public release and website flags. The complete maintainer procedure is [Windows signed release and publication](../playbooks/windows-signed-release.md). From 0cdf9bcfcd7c8943357c1d82d54b69a7e1c0578a Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 23:26:10 -0400 Subject: [PATCH 06/12] docs(windows): keep packaging layer links self-contained Based-on: nsxdavid/ADE#999 --- docs/development/windows-port-lane.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/development/windows-port-lane.md b/docs/development/windows-port-lane.md index c108bce5e..8016c259b 100644 --- a/docs/development/windows-port-lane.md +++ b/docs/development/windows-port-lane.md @@ -98,8 +98,8 @@ Do these to move from “runs on Windows” to “first-class for Windows users the public-release and installed-update-proof gates are both enabled. Only then may a maintainer enable public release and website flags. -The complete maintainer procedure is -[Windows signed release and publication](../playbooks/windows-signed-release.md). +The cumulative release-proof layer adds the complete Windows signed-release +and publication procedure before this stack can be considered ready. ## Suggested validation commands (from repo root) From 73a927c4ace098709ffa07820202343c9b031a33 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 23:32:15 -0400 Subject: [PATCH 07/12] feat(remote-runtime): bootstrap native Windows SSH targets Add Windows 10/11 x64 platform detection, encoded PowerShell control, verified SFTP artifact installation, channel-aware runtime launch, discovery support, packaging coverage, and focused regression tests. Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 (cherry picked from commit f1158a24ab56ada7a405adf54cf5a3c1f295e6a0) --- apps/desktop/package.json | 4 +- .../scripts/materialize-runtime-resources.mjs | 6 +- .../scripts/validate-win-artifacts.mjs | 11 +- .../scripts/windows-release-contract.test.mjs | 5 +- .../remoteRuntime/remoteBootstrap.test.ts | 211 +++++- .../services/remoteRuntime/remoteBootstrap.ts | 608 +++++++++++++++++- .../remoteRuntime/runtimeDiscovery.test.ts | 10 +- .../remoteRuntime/runtimeDiscovery.ts | 13 +- docs/ARCHITECTURE.md | 2 +- docs/development/windows-port-lane.md | 5 +- docs/features/remote-runtime/README.md | 2 +- 11 files changed, 833 insertions(+), 44 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2bc67660b..eb074574b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -279,7 +279,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" ] }, { 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/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index 9de03f77f..1024a64e7 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -30,7 +30,11 @@ const DEFAULT_MAX_APP_ASAR_BYTES = 900 * 1024 * 1024; // 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" && @@ -301,8 +305,7 @@ function validatePreflight() { const runtimeFilter = new Set(runtimeResourceFilter()); for (const target of REMOTE_RUNTIME_TARGETS) { - for (const suffix of ["", ".native.tar.gz"]) { - const fileName = `ade-${target}${suffix}`; + 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}`); } @@ -438,7 +441,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}`); diff --git a/apps/desktop/scripts/windows-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index a03ac6e48..c84a4545f 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -58,7 +58,7 @@ const registerIpc = fs.readFileSync( "utf8", ); -const remoteTargets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; +const remoteTargets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-x64"]; function jobBlock(workflow, jobName, nextJobName) { const start = workflow.indexOf(`\n ${jobName}:\n`); @@ -73,7 +73,8 @@ test("Windows package carries every remote runtime sidecar its validator require .filter((entry) => entry.to === "runtime"); const runtimeFilter = runtimeResources.flatMap((entry) => entry.filter); for (const target of remoteTargets) { - assert.ok(runtimeFilter.includes(`ade-${target}`), target); + 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; diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 820d4e00b..2ad665505 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -15,12 +15,17 @@ import { coerceProjects, formatOpenSshSpawnError, normalizeRemoteArch, + parseWindowsRemoteDetection, normalizeRuntimeVersion, resolveRemoteRuntimeLayout, resolveRemoteRuntimeLayoutCandidates, + resolveWindowsRemoteRuntimeLayout, selectRemoteRuntimeVersion, shouldUploadBundledRuntime, validateRemoteRuntimeInitializeResult, + windowsPowerShellCommand, + windowsRuntimeRpcCommand, + windowsSftpPath, } from "./remoteBootstrap"; const connectSshWithRouteMock = vi.hoisted(() => vi.fn()); @@ -98,6 +103,70 @@ describe("formatOpenSshSpawnError", () => { }); }); +describe("Windows remote runtime bootstrap contracts", () => { + const detection = (overrides: Record = {}) => JSON.stringify({ + platform: "win32", + arch: "X64", + productName: "Windows 11 Pro", + displayVersion: "24H2", + buildNumber: 26100, + userProfile: "C:\\Users\\Ada Dev", + sshdStatus: "Running", + ...overrides, + }); + + it("accepts supported Windows 10/11 x64 metadata and rejects ARM64, Server, and pre-22H2", () => { + expect(parseWindowsRemoteDetection(detection())).toMatchObject({ + platform: "win32", + arch: "x64", + label: "win32-x64", + buildNumber: 26100, + }); + expect(parseWindowsRemoteDetection(detection({ + productName: "Windows 10 Pro", + displayVersion: "22H2", + buildNumber: 19045, + })).buildNumber).toBe(19045); + expect(() => parseWindowsRemoteDetection(detection({ arch: "Arm64" }))).toThrow(/x64 only/i); + expect(() => parseWindowsRemoteDetection(detection({ productName: "Windows Server 2025" }))).toThrow(/not Windows Server/i); + expect(() => parseWindowsRemoteDetection(detection({ productName: "Windows 10 Pro", buildNumber: 19044 }))).toThrow(/build 19045/i); + }); + + it("builds channel-separated native paths and SFTP paths when the profile contains spaces", () => { + const layout = resolveWindowsRemoteRuntimeLayout("C:\\Users\\Ada Dev", { + ADE_PACKAGE_CHANNEL: "beta", + } as NodeJS.ProcessEnv); + expect(layout.adeHome).toBe("C:\\Users\\Ada Dev\\.ade-beta"); + expect(layout.binary).toBe("C:\\Users\\Ada Dev\\.ade-beta\\bin\\ade.exe"); + expect(windowsSftpPath(layout.binary)).toBe("/C:/Users/Ada Dev/.ade-beta/bin/ade.exe"); + }); + + it("encodes PowerShell so profile spaces and metacharacters never appear in the SSH command", () => { + const profile = "C:\\Users\\Ada Dev & Ops's [lab]"; + const layout = resolveWindowsRemoteRuntimeLayout(profile, {} as NodeJS.ProcessEnv); + const command = windowsRuntimeRpcCommand({ + layout, + nativeDepsReady: true, + ptyHostWorkerReady: true, + }); + expect(command).toMatch(/^powershell.exe .* -EncodedCommand [A-Za-z0-9+/=]+$/); + expect(command).not.toContain(profile); + expect(command).not.toContain("& Ops"); + const encoded = command.split(" ").at(-1) ?? ""; + const script = Buffer.from(encoded, "base64").toString("utf16le"); + expect(script).toContain("Ada Dev & Ops''s [lab]"); + expect(script).toContain("'rpc', '--stdio'"); + expect(script).toContain("$env:NODE_PATH"); + }); + + it("uses a fixed encoded command for platform probes", () => { + const command = windowsPowerShellCommand("Write-Output 'ok'"); + expect(command).toMatch(/^powershell.exe .* -EncodedCommand [A-Za-z0-9+/=]+$/); + expect(Buffer.from(command.split(" ").at(-1) ?? "", "base64").toString("utf16le")) + .toBe("Write-Output 'ok'"); + }); +}); + describe("normalizeRuntimeVersion", () => { it("normalizes plain and prefixed ADE version output", () => { expect(normalizeRuntimeVersion("1.0.0-beta.1\n")).toBe("1.0.0-beta.1"); @@ -477,7 +546,9 @@ function createTempResources( const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-remote-runtime-")); const runtimeDir = path.join(resourcesPath, "runtime"); fs.mkdirSync(runtimeDir, { recursive: true }); - const binaryPath = path.join(runtimeDir, `ade-${archLabel}`); + const binaryPath = path.join(runtimeDir, archLabel === "win32-x64" + ? `ade-${archLabel}.exe` + : `ade-${archLabel}`); fs.writeFileSync(binaryPath, "#!/bin/sh\n"); if (options.nativeDeps) { fs.writeFileSync(path.join(runtimeDir, `ade-${archLabel}.native.tar.gz`), "native deps fixture\n"); @@ -639,6 +710,144 @@ describe("bootstrapRemoteRuntime upload flow", () => { cleanupResources?.(); }); + it("bootstraps Windows x64 through encoded PowerShell and verified SFTP", async () => { + const resources = createTempResources("win32-x64", { + agentSkills: true, + nativeDeps: true, + ptyHostWorker: true, + }); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + const registry = createRegistry(); + const userProfile = "C:\\Users\\Ada Dev & Ops's [lab]"; + connectSshWithRouteMock.mockResolvedValue({ + client: fakeSsh.ssh, + route: uploadRoute, + config: { host: uploadRoute.hostname, port: 22, username: "ada" }, + }); + execSshMock.mockImplementation(async (_client: Client, command: string) => { + if (command === "uname -sm") return { code: 1, stdout: "", stderr: "'uname' is not recognized" }; + return ok(JSON.stringify({ + platform: "win32", + arch: "X64", + productName: "Windows 11 Pro", + displayVersion: "24H2", + buildNumber: 26100, + userProfile, + sshdStatus: "Running", + })); + }); + let identityReads = 0; + execSshWithInputMock.mockImplementation(async (_client: Client, command: string, input: string) => { + expect(command).toMatch(/^powershell.exe .* -EncodedCommand [A-Za-z0-9+/=]+$/); + expect(command).not.toContain(userProfile); + const script = Buffer.from(command.split(" ").at(-1) ?? "", "base64").toString("utf16le"); + const params = JSON.parse(input) as Record; + if (script.includes("selectedBinary = $selected")) { + identityReads += 1; + return ok(JSON.stringify(identityReads === 1 ? { + selectedBinary: null, + executableVersion: null, + markerVersion: null, + markerSha256: null, + actualSha256: null, + } : { + selectedBinary: path.win32.join(userProfile, ".ade", "bin", "ade.exe"), + executableVersion: `ade ${APP_VERSION}`, + markerVersion: APP_VERSION, + markerSha256: resources.binarySha256, + actualSha256: resources.binarySha256, + })); + } + if (script.includes("AvailableFreeSpace")) { + return ok(JSON.stringify({ availableBytes: 2 * 1024 * 1024 * 1024 })); + } + if (script.includes("Uploaded file checksum mismatch") && params.destination === path.win32.join(userProfile, ".ade", "bin", "ade.exe")) { + expect(params).toMatchObject({ + destination: path.win32.join(userProfile, ".ade", "bin", "ade.exe"), + sha256: resources.binarySha256, + }); + } + return ok(JSON.stringify({ ok: true })); + }); + + const connected = await bootstrapRemoteRuntime({ + target: uploadTarget, + registry, + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + }); + + expect(connected.result).toMatchObject({ arch: "win32-x64", version: APP_VERSION }); + expect(identityReads).toBe(2); + expect(fakeSsh.sftp).toHaveBeenCalledTimes(4); + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + resources.binaryPath, + expect.stringContaining("/C:/Users/Ada Dev & Ops's [lab]/.ade/bin/ade.exe.tmp-"), + expect.objectContaining({ fileSize: fs.statSync(resources.binaryPath).size }), + expect.any(Function), + ); + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + expect.stringContaining("ade-win32-x64.native.tar.gz"), + expect.stringContaining("/C:/Users/Ada Dev & Ops's [lab]/.ade/runtime/ade-win32-x64.native.tar.gz.tmp-"), + expect.any(Object), + expect.any(Function), + ); + expect(fakeSsh.sftpWrapper.fastPut).toHaveBeenCalledWith( + resources.ptyHostWorkerPath, + expect.stringContaining("/C:/Users/Ada Dev & Ops's [lab]/.ade/runtime/ptyHostWorker.cjs.tmp-"), + expect.any(Object), + expect.any(Function), + ); + expect(fakeSsh.sftpWrapper.fastPut.mock.calls.some((call) => + call[0] === resources.agentSkillPath + && String(call[1]).includes("agent-skills.tmp-") + && String(call[1]).includes("/ade-cli-control-plane/SKILL.md.tmp-"), + )).toBe(true); + const rpcCommand = openSshRuntimeTransportMock.mock.calls[0]?.[1] as string; + expect(rpcCommand).not.toContain(userProfile); + const rpcScript = Buffer.from(rpcCommand.split(" ").at(-1) ?? "", "base64").toString("utf16le"); + expect(rpcScript).toContain("Ada Dev & Ops''s [lab]"); + expect(rpcScript).toContain("'rpc', '--stdio'"); + }); + + it("rejects a Windows runtime upload when remote checksum verification fails", async () => { + const resources = createTempResources("win32-x64"); + cleanupResources = resources.cleanup; + const fakeSsh = createFakeSsh(); + connectSshWithRouteMock.mockResolvedValue({ client: fakeSsh.ssh, route: uploadRoute, config: {} }); + execSshMock.mockImplementation(async (_client: Client, command: string) => command === "uname -sm" + ? { code: 1, stdout: "", stderr: "not found" } + : ok(JSON.stringify({ + platform: "win32", + arch: "X64", + productName: "Windows 10 Pro", + displayVersion: "22H2", + buildNumber: 19045, + userProfile: "C:\\Users\\Ada", + sshdStatus: "Running", + }))); + execSshWithInputMock.mockImplementation(async (_client: Client, command: string) => { + const script = Buffer.from(command.split(" ").at(-1) ?? "", "base64").toString("utf16le"); + if (script.includes("selectedBinary = $selected")) { + return ok(JSON.stringify({ selectedBinary: null, executableVersion: null, markerVersion: null, markerSha256: null, actualSha256: null })); + } + if (script.includes("AvailableFreeSpace")) return ok(JSON.stringify({ availableBytes: 2 * 1024 * 1024 * 1024 })); + if (script.includes("Uploaded file checksum mismatch")) { + return { code: 1, stdout: "", stderr: "Uploaded file checksum mismatch." }; + } + return ok(JSON.stringify({ ok: true })); + }); + + await expect(bootstrapRemoteRuntime({ + target: uploadTarget, + registry: createRegistry(), + resourcesPath: resources.resourcesPath, + appVersion: APP_VERSION, + })).rejects.toThrow(/checksum mismatch/i); + expect(fakeSsh.end).toHaveBeenCalled(); + }); + it("uploads a missing bundled runtime, verifies its version, and opens stdio RPC from ~/.ade/bin", async () => { const resources = createTempResources(); cleanupResources = resources.cleanup; diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index b12ed4b1e..049e52849 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -19,13 +19,17 @@ import { RuntimeRpcClient } from "./runtimeRpcClient"; import { connectSshWithRoute, execSsh, execSshWithInput, openSshRuntimeTransport, type ConnectedSshRoute, type OpenSshResolvedConfig } from "./sshTransport"; import { routeKey } from "./routeUtils"; import { syncEndpointForHost } from "./pairedRuntimeRoutes"; -import { DesktopPairedMachineStore, generateDesktopDpopKeyPair } from "./syncPairedMachineStore"; +import { generateDesktopDpopKeyPair, type DesktopPairedMachineStore } from "./syncPairedMachineStore"; import { normalizeRemoteTargetRoutes, type RemoteTargetRegistry, } from "./remoteTargetRegistry"; -export function normalizeRemoteArch(raw: string): { platform: string; arch: string; label: string } { +export function normalizeRemoteArch(raw: string): { + platform: "darwin" | "linux"; + arch: "arm64" | "x64"; + label: string; +} { const lower = raw.toLowerCase(); const platform = lower.includes("darwin") ? "darwin" @@ -41,7 +45,7 @@ export function normalizeRemoteArch(raw: string): { platform: string; arch: stri const detectedPlatform = raw.trim() || "unknown"; throw new RemoteRuntimeConnectError({ kind: "unsupported_os", - message: `Unsupported remote ADE service platform: ${detectedPlatform}. Supported targets are macOS/Linux on arm64 or x64.`, + message: `Unsupported remote ADE service platform: ${detectedPlatform}. Supported targets are macOS/Linux on arm64 or x64, and Windows 10 22H2/Windows 11 on x64.`, detail: detectedPlatform, }); } @@ -173,6 +177,108 @@ export function validateRemoteRuntimeInitializeResult(args: { type RemoteRuntimeChannel = "alpha" | "beta" | null; +type WindowsRemotePlatform = { + platform: "win32"; + arch: "x64"; + label: "win32-x64"; + productName: string; + displayVersion: string; + buildNumber: number; + userProfile: string; +}; + +type DetectedRemotePlatform = ReturnType | WindowsRemotePlatform; + +const WINDOWS_REMOTE_DETECTION_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$cv = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' +$service = Get-Service -Name 'sshd' -ErrorAction SilentlyContinue +[ordered]@{ + platform = 'win32' + arch = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + productName = [string]$cv.ProductName + displayVersion = [string]$cv.DisplayVersion + buildNumber = [int]$cv.CurrentBuildNumber + userProfile = [Environment]::GetFolderPath('UserProfile') + sshdStatus = if ($service) { [string]$service.Status } else { 'Missing' } +} | ConvertTo-Json -Compress +`; + +export function encodeWindowsPowerShellCommand(script: string): string { + return Buffer.from(script, "utf16le").toString("base64"); +} + +export function windowsPowerShellCommand(script: string): string { + return `powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodeWindowsPowerShellCommand(script)}`; +} + +export function parseWindowsRemoteDetection(raw: string): WindowsRemotePlatform { + let value: unknown; + try { + value = JSON.parse(raw.trim()); + } catch (error) { + throw new Error("Windows OpenSSH returned invalid platform metadata.", { cause: error }); + } + if (!isRecord(value)) throw new Error("Windows OpenSSH returned invalid platform metadata."); + const productName = typeof value.productName === "string" ? value.productName.trim() : ""; + const displayVersion = typeof value.displayVersion === "string" ? value.displayVersion.trim() : ""; + const buildNumber = typeof value.buildNumber === "number" ? value.buildNumber : Number(value.buildNumber); + const userProfile = typeof value.userProfile === "string" ? value.userProfile.trim() : ""; + const archRaw = typeof value.arch === "string" ? value.arch.trim().toLowerCase() : ""; + const sshdStatus = typeof value.sshdStatus === "string" ? value.sshdStatus.trim().toLowerCase() : ""; + if (sshdStatus === "missing") { + throw new Error("Windows OpenSSH Server is not installed. Install OpenSSH Server, start the sshd service, and allow it through Windows Firewall before reconnecting."); + } + if (productName.toLowerCase().includes("server")) { + throw new RemoteRuntimeConnectError({ + kind: "unsupported_os", + message: `Unsupported remote ADE service platform: ${productName}. Windows v1 supports Windows 10 22H2 and Windows 11 x64, not Windows Server.`, + detail: productName, + }); + } + if (archRaw !== "x64" && archRaw !== "amd64") { + throw new RemoteRuntimeConnectError({ + kind: "unsupported_os", + message: `Unsupported remote ADE service architecture: ${archRaw || "unknown"}. Windows v1 supports x64 only; ARM64 and WSL are not supported.`, + detail: archRaw || "unknown", + }); + } + if (!productName.toLowerCase().includes("windows") || !Number.isInteger(buildNumber) || buildNumber < 19045) { + throw new RemoteRuntimeConnectError({ + kind: "unsupported_os", + message: `Unsupported remote ADE service platform: ${productName || "Windows"} build ${Number.isFinite(buildNumber) ? buildNumber : "unknown"}. Windows v1 requires Windows 10 22H2 (build 19045) or Windows 11 x64.`, + detail: `${productName || "Windows"} ${displayVersion} build ${Number.isFinite(buildNumber) ? buildNumber : "unknown"}`.trim(), + }); + } + if (!path.win32.isAbsolute(userProfile)) { + throw new Error("Windows OpenSSH did not report an absolute user profile path."); + } + return { + platform: "win32", + arch: "x64", + label: "win32-x64", + productName, + displayVersion, + buildNumber, + userProfile: path.win32.normalize(userProfile), + }; +} + +async function detectRemotePlatform(client: Client): Promise { + const uname = await execSsh(client, "uname -sm"); + if (uname.code === 0 && !/(?:mingw|msys|cygwin|windows_nt)/iu.test(uname.stdout)) { + return normalizeRemoteArch(uname.stdout.trim()); + } + const windows = await execSsh(client, windowsPowerShellCommand(WINDOWS_REMOTE_DETECTION_SCRIPT)); + if (windows.code !== 0) { + throw new Error( + windows.stderr.trim() + || "Unable to detect the remote platform. On Windows, install and start OpenSSH Server and ensure Windows PowerShell 5.1 is available to the SSH account.", + ); + } + return parseWindowsRemoteDetection(windows.stdout); +} + type RemoteRuntimeLayout = { channel: RemoteRuntimeChannel; homeDirName: ".ade" | ".ade-alpha" | ".ade-beta"; @@ -284,10 +390,11 @@ function shellQuote(value: string): string { } function bundledRuntimePath(resourcesPath: string, archLabel: string): string | null { + const binaryName = archLabel === "win32-x64" ? `ade-${archLabel}.exe` : `ade-${archLabel}`; const candidates = [ - path.join(resourcesPath, "runtime", `ade-${archLabel}`), - path.join(resourcesPath, "app.asar.unpacked", "runtime", `ade-${archLabel}`), - path.resolve(process.cwd(), "resources", "runtime", `ade-${archLabel}`), + path.join(resourcesPath, "runtime", binaryName), + path.join(resourcesPath, "app.asar.unpacked", "runtime", binaryName), + path.resolve(process.cwd(), "resources", "runtime", binaryName), ]; return candidates.find((candidate) => { try { @@ -496,6 +603,15 @@ async function uploadSftpFile( totalBytes: number, ): Promise { const remotePath = await resolveRemoteUploadPath(client, remoteFileExpr); + await uploadSftpResolvedFile(client, localPath, remotePath, totalBytes); +} + +async function uploadSftpResolvedFile( + client: Client, + localPath: string, + remotePath: string, + totalBytes: number, +): Promise { const sftp = await openSftp(client); try { await new Promise((resolve, reject) => { @@ -1435,6 +1551,7 @@ async function upgradeSshTargetToPairedCredentials(args: { appVersion: string; connectedRoute: RemoteRuntimeTargetRoute; store: DesktopPairedMachineStore; + commandOverride?: string; }): Promise { const keys = generateDesktopDpopKeyPair(); const deviceId = crypto.randomUUID(); @@ -1459,7 +1576,8 @@ async function upgradeSshTargetToPairedCredentials(args: { appVersion: args.appVersion, }, }; - const command = `${args.runtimeEnvPrefix}${args.binaryExpr} --socket ${args.layout.socketExpr} --json sync pair-device --json-stdin`; + const command = args.commandOverride + ?? `${args.runtimeEnvPrefix}${args.binaryExpr} --socket ${args.layout.socketExpr} --json sync pair-device --json-stdin`; const result = await execSshWithInput( args.ssh, command, @@ -1656,6 +1774,475 @@ export function markRemoteTargetRouteSucceeded(args: { return updated; } +type WindowsRemoteRuntimeLayout = { + channelLayout: RemoteRuntimeLayout; + adeHome: string; + binDir: string; + runtimeDir: string; + binary: string; + versionMarker: string; + sha256Marker: string; + nativeDir: string; + nativeVersionMarker: string; + ptyHostWorker: string; + ptyHostWorkerSha256: string; + agentSkillsDir: string; + agentSkillsSha256: string; +}; + +export function resolveWindowsRemoteRuntimeLayout( + userProfile: string, + env: NodeJS.ProcessEnv = process.env, +): WindowsRemoteRuntimeLayout { + if (!path.win32.isAbsolute(userProfile)) { + throw new Error("Windows OpenSSH did not report an absolute user profile path."); + } + const channelLayout = resolveRemoteRuntimeLayout(env); + const adeHome = path.win32.join(path.win32.normalize(userProfile), channelLayout.homeDirName); + const binDir = path.win32.join(adeHome, "bin"); + const runtimeDir = path.win32.join(adeHome, "runtime"); + const nativeDir = path.win32.join(runtimeDir, "win32-x64"); + return { + channelLayout, + adeHome, + binDir, + runtimeDir, + binary: path.win32.join(binDir, "ade.exe"), + versionMarker: path.win32.join(binDir, "ade.version"), + sha256Marker: path.win32.join(binDir, "ade.sha256"), + nativeDir, + nativeVersionMarker: path.win32.join(nativeDir, ".ade-version"), + ptyHostWorker: path.win32.join(runtimeDir, "ptyHostWorker.cjs"), + ptyHostWorkerSha256: path.win32.join(runtimeDir, "ptyHostWorker.cjs.sha256"), + agentSkillsDir: path.win32.join(adeHome, "agent-skills"), + agentSkillsSha256: path.win32.join(adeHome, "agent-skills.sha256"), + }; +} + +export function windowsSftpPath(nativePath: string): string { + const normalized = path.win32.normalize(nativePath); + const match = /^([a-z]):\\(.*)$/i.exec(normalized); + if (!match) throw new Error(`Windows SFTP requires an absolute drive-letter path: ${nativePath}`); + return `/${match[1].toUpperCase()}:/${match[2].replace(/\\/g, "/")}`; +} + +function powerShellLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +async function execWindowsPowerShellJson( + client: Client, + script: string, + params: Record, + fallback: string, + timeoutMs = 30_000, +): Promise { + const result = await execSshWithInput( + client, + windowsPowerShellCommand(script), + JSON.stringify(params), + { timeoutMs }, + ); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || fallback); + } + const output = result.stdout.trim(); + if (!output) return undefined as T; + try { + return JSON.parse(output) as T; + } catch (error) { + throw new Error(`${fallback} Windows PowerShell returned invalid JSON.`, { cause: error }); + } +} + +const WINDOWS_ENSURE_DIRECTORIES_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$p = [Console]::In.ReadToEnd() | ConvertFrom-Json +foreach ($directory in $p.directories) { + [IO.Directory]::CreateDirectory([string]$directory) | Out-Null +} +@{ ok = $true } | ConvertTo-Json -Compress +`; + +const WINDOWS_RUNTIME_IDENTITY_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$p = [Console]::In.ReadToEnd() | ConvertFrom-Json +$binary = [string]$p.binary +$pathBinary = $null +if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { + $candidate = Get-Command ade.exe, ade -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($candidate) { $pathBinary = [string]$candidate.Source } +} +$selected = if (Test-Path -LiteralPath $binary -PathType Leaf) { $binary } else { $pathBinary } +$version = $null +if ($selected) { + $versionOutput = & $selected '--version' 2>$null | Out-String + if ($LASTEXITCODE -eq 0) { $version = $versionOutput.Trim() } +} +@{ + selectedBinary = $selected + executableVersion = $version + markerVersion = if (Test-Path -LiteralPath $p.versionMarker) { [IO.File]::ReadAllText([string]$p.versionMarker).Trim() } else { $null } + markerSha256 = if (Test-Path -LiteralPath $p.sha256Marker) { [IO.File]::ReadAllText([string]$p.sha256Marker).Trim().ToLowerInvariant() } else { $null } + actualSha256 = if ($selected) { (Get-FileHash -LiteralPath $selected -Algorithm SHA256).Hash.ToLowerInvariant() } else { $null } +} | ConvertTo-Json -Compress +`; + +const WINDOWS_FINALIZE_FILE_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$p = [Console]::In.ReadToEnd() | ConvertFrom-Json +$file = Get-Item -LiteralPath $p.temp -ErrorAction Stop +if ($file.Length -ne [int64]$p.size) { throw "Uploaded file size mismatch." } +$hash = (Get-FileHash -LiteralPath $p.temp -Algorithm SHA256).Hash.ToLowerInvariant() +if ($hash -ne ([string]$p.sha256).ToLowerInvariant()) { throw "Uploaded file checksum mismatch." } +[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName([string]$p.destination)) | Out-Null +Move-Item -LiteralPath $p.temp -Destination $p.destination -Force +if ($p.markerPath) { [IO.File]::WriteAllText([string]$p.markerPath, [string]$p.markerValue + [Environment]::NewLine) } +@{ ok = $true; sha256 = $hash } | ConvertTo-Json -Compress +`; + +const WINDOWS_NATIVE_DEPS_STATUS_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$p = [Console]::In.ReadToEnd() | ConvertFrom-Json +@{ ready = (Test-Path -LiteralPath $p.nodeModules -PathType Container) -and (Test-Path -LiteralPath $p.versionMarker -PathType Leaf) -and ([IO.File]::ReadAllText([string]$p.versionMarker).Trim() -eq [string]$p.version) } | ConvertTo-Json -Compress +`; + +const WINDOWS_INSTALL_NATIVE_DEPS_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$p = [Console]::In.ReadToEnd() | ConvertFrom-Json +$archive = Get-Item -LiteralPath $p.temp -ErrorAction Stop +if ($archive.Length -ne [int64]$p.size) { throw "Native dependency archive size mismatch." } +$hash = (Get-FileHash -LiteralPath $p.temp -Algorithm SHA256).Hash.ToLowerInvariant() +if ($hash -ne ([string]$p.sha256).ToLowerInvariant()) { throw "Native dependency archive checksum mismatch." } +$tar = Get-Command tar.exe -ErrorAction SilentlyContinue +if (-not $tar) { throw "Windows tar.exe is unavailable. Install the Windows tar component before reconnecting." } +if (Test-Path -LiteralPath $p.staging) { Remove-Item -LiteralPath $p.staging -Recurse -Force } +[IO.Directory]::CreateDirectory([string]$p.staging) | Out-Null +& $tar.Source '-xzf' $p.temp '-C' $p.staging +if ($LASTEXITCODE -ne 0) { throw "Unable to unpack ADE native dependencies." } +if (-not (Test-Path -LiteralPath (Join-Path $p.staging 'node_modules') -PathType Container)) { throw "ADE native dependency archive did not contain node_modules." } +if (Test-Path -LiteralPath $p.destination) { Remove-Item -LiteralPath $p.destination -Recurse -Force } +Move-Item -LiteralPath $p.staging -Destination $p.destination -Force +[IO.File]::WriteAllText([string]$p.versionMarker, [string]$p.version + [Environment]::NewLine) +Remove-Item -LiteralPath $p.temp -Force +@{ ok = $true; sha256 = $hash } | ConvertTo-Json -Compress +`; + +const WINDOWS_FREE_SPACE_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$p = [Console]::In.ReadToEnd() | ConvertFrom-Json +$root = [IO.Path]::GetPathRoot([string]$p.path) +$drive = [IO.DriveInfo]::new($root) +@{ availableBytes = [int64]$drive.AvailableFreeSpace } | ConvertTo-Json -Compress +`; + +async function windowsUploadVerifiedFile(args: { + ssh: Client; + localPath: string; + destination: string; + markerPath?: string; + markerValue?: string; +}): Promise { + const temp = `${args.destination}.tmp-${crypto.randomBytes(8).toString("hex")}`; + await execWindowsPowerShellJson(args.ssh, WINDOWS_ENSURE_DIRECTORIES_SCRIPT, { + directories: [path.win32.dirname(temp)], + }, "Unable to prepare the Windows ADE upload directory."); + try { + await uploadSftpResolvedFile(args.ssh, args.localPath, windowsSftpPath(temp), fileSizeBytes(args.localPath)); + await execWindowsPowerShellJson(args.ssh, WINDOWS_FINALIZE_FILE_SCRIPT, { + temp, + destination: args.destination, + size: fileSizeBytes(args.localPath), + sha256: hashLocalFile(args.localPath), + markerPath: args.markerPath ?? null, + markerValue: args.markerValue ?? null, + }, "Uploaded Windows ADE artifact did not pass size and checksum verification.", REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS); + } catch (error) { + await execWindowsPowerShellJson(args.ssh, String.raw` + $p = [Console]::In.ReadToEnd() | ConvertFrom-Json + Remove-Item -LiteralPath $p.path -Force -ErrorAction SilentlyContinue + @{ ok = $true } | ConvertTo-Json -Compress + `, { path: temp }, "Unable to clean up a partial Windows ADE upload.").catch(() => undefined); + throw error; + } +} + +function windowsRuntimeEnvironmentScript(args: { + layout: WindowsRemoteRuntimeLayout; + nativeDepsReady: boolean; + ptyHostWorkerReady: boolean; +}): string { + const assignments = [ + `$env:ADE_HOME = ${powerShellLiteral(args.layout.adeHome)}`, + `$env:ADE_DEFAULT_ROLE = 'cto'`, + `$env:PATH = ${powerShellLiteral(`${args.layout.binDir};`)} + $env:PATH`, + ]; + if (args.layout.channelLayout.channel) { + assignments.push(`$env:ADE_PACKAGE_CHANNEL = ${powerShellLiteral(args.layout.channelLayout.channel)}`); + } + if (args.nativeDepsReady) { + assignments.push(`$env:NODE_PATH = ${powerShellLiteral(path.win32.join(args.layout.nativeDir, "node_modules"))}`); + } + if (args.ptyHostWorkerReady) { + assignments.push(`$env:ADE_PTY_HOST_WORKER_PATH = ${powerShellLiteral(args.layout.ptyHostWorker)}`); + assignments.push(`$env:ADE_PTY_HOST_WORKER_COMMAND = ${powerShellLiteral(args.layout.binary)}`); + } + return assignments.join("\n"); +} + +export function windowsRuntimeRpcCommand(args: { + layout: WindowsRemoteRuntimeLayout; + nativeDepsReady: boolean; + ptyHostWorkerReady: boolean; + pairDevice?: boolean; +}): string { + const commandArgs = args.pairDevice + ? "'--json', 'sync', 'pair-device', '--json-stdin'" + : "'rpc', '--stdio'"; + return windowsPowerShellCommand(String.raw` +$ErrorActionPreference = 'Stop' +${windowsRuntimeEnvironmentScript(args)} +& ${powerShellLiteral(args.layout.binary)} ${commandArgs} +exit $LASTEXITCODE +`); +} + +type WindowsRuntimeIdentity = { + selectedBinary: string | null; + executableVersion: string | null; + markerVersion: string | null; + markerSha256: string | null; + actualSha256: string | null; +}; + +async function readWindowsRuntimeIdentity( + ssh: Client, + layout: WindowsRemoteRuntimeLayout, +): Promise { + return execWindowsPowerShellJson(ssh, WINDOWS_RUNTIME_IDENTITY_SCRIPT, { + binary: layout.binary, + versionMarker: layout.versionMarker, + sha256Marker: layout.sha256Marker, + }, "Unable to inspect the Windows ADE runtime."); +} + +async function bootstrapWindowsRemoteRuntime(args: { + request: Parameters[0]; + ssh: Client; + connectedRoute: ConnectedSshRoute; + platform: WindowsRemotePlatform; +}): Promise<{ client: RuntimeRpcClient; result: RemoteRuntimeConnectResult; ssh: Client }> { + const { request, ssh, connectedRoute, platform } = args; + const preferredLayout = resolveWindowsRemoteRuntimeLayout(platform.userProfile); + let layout = preferredLayout; + let identity = await readWindowsRuntimeIdentity(ssh, layout); + const localBinary = bundledRuntimePath(request.resourcesPath, platform.label); + const localBinarySha256 = localBinary ? hashRuntimeBinary(localBinary) : null; + const localNativeDeps = bundledNativeDepsPath(request.resourcesPath, platform.label); + const localPtyHostWorker = bundledPtyHostWorkerPath(request.resourcesPath, localBinary); + const localAgentSkillsRoot = bundledAgentSkillsPath(request.resourcesPath, localBinary); + const installedVersion = selectRemoteRuntimeVersion({ + markerVersion: identity.markerVersion, + executableVersion: normalizeRuntimeVersion(identity.executableVersion ?? ""), + }); + const shouldUploadRuntime = Boolean(localBinary && localBinarySha256 && shouldUploadBundledRuntime({ + localBinaryAvailable: true, + executableVersion: normalizeRuntimeVersion(identity.executableVersion ?? ""), + markerVersion: identity.markerVersion, + appVersion: request.appVersion, + localBinarySha256, + remoteBinarySha256: identity.actualSha256, + remoteBinaryMatchesLocal: identity.actualSha256 === localBinarySha256, + })); + const uploadBytes = (shouldUploadRuntime && localBinary ? fileSizeBytes(localBinary) : 0) + + (localNativeDeps ? fileSizeBytes(localNativeDeps) : 0) + + (localPtyHostWorker ? fileSizeBytes(localPtyHostWorker) : 0); + if (uploadBytes > 0) { + const disk = await execWindowsPowerShellJson<{ availableBytes: number }>( + ssh, + WINDOWS_FREE_SPACE_SCRIPT, + { path: layout.adeHome }, + "Unable to inspect free disk space on the Windows ADE runtime.", + ); + const requiredBytes = uploadBytes + REMOTE_INSTALL_MARGIN_BYTES; + if (!Number.isFinite(disk.availableBytes) || disk.availableBytes < requiredBytes) { + throw new RemoteRuntimeConnectError({ + kind: "disk_full", + message: "ADE needs more free disk space on the Windows machine before it can install the remote runtime.", + detail: `Required ${formatInstallMegabytes(requiredBytes)}; available ${formatInstallMegabytes(Number(disk.availableBytes) || 0)}.`, + }); + } + } + if (shouldUploadRuntime && localBinary) { + await windowsUploadVerifiedFile({ + ssh, + localPath: localBinary, + destination: layout.binary, + markerPath: layout.versionMarker, + markerValue: request.appVersion, + }); + await execWindowsPowerShellJson(ssh, String.raw` + $p = [Console]::In.ReadToEnd() | ConvertFrom-Json + [IO.File]::WriteAllText([string]$p.path, [string]$p.value + [Environment]::NewLine) + @{ ok = $true } | ConvertTo-Json -Compress + `, { path: layout.sha256Marker, value: localBinarySha256 }, "Unable to record the Windows ADE runtime checksum."); + identity = await readWindowsRuntimeIdentity(ssh, layout); + } + if (!identity.selectedBinary) { + throw new Error(`ADE is not installed on the Windows machine and no bundled ade-win32-x64.exe is available. Rebuild the desktop runtime resources, or install the standalone Windows ADE runtime and reconnect.`); + } + layout = { ...layout, binary: identity.selectedBinary }; + const runtimeVersion = selectRemoteRuntimeVersion({ + markerVersion: identity.markerVersion, + executableVersion: normalizeRuntimeVersion(identity.executableVersion ?? ""), + }); + if (localBinary && runtimeVersion !== request.appVersion) { + throw new Error(`Uploaded Windows ADE runtime version mismatch: expected ${request.appVersion}, got ${runtimeVersion ?? "unknown"}.`); + } + + let nativeDepsReady = false; + if (localNativeDeps) { + const nativeStatus = await execWindowsPowerShellJson<{ ready: boolean }>(ssh, WINDOWS_NATIVE_DEPS_STATUS_SCRIPT, { + nodeModules: path.win32.join(layout.nativeDir, "node_modules"), + versionMarker: layout.nativeVersionMarker, + version: request.appVersion, + }, "Unable to inspect Windows ADE native dependencies."); + if (!nativeStatus.ready) { + const suffix = crypto.randomBytes(8).toString("hex"); + const temp = path.win32.join(layout.runtimeDir, `ade-win32-x64.native.tar.gz.tmp-${suffix}`); + const staging = `${layout.nativeDir}.tmp-${suffix}`; + await execWindowsPowerShellJson(ssh, WINDOWS_ENSURE_DIRECTORIES_SCRIPT, { + directories: [layout.runtimeDir], + }, "Unable to prepare the Windows ADE native dependency directory."); + try { + await uploadSftpResolvedFile(ssh, localNativeDeps, windowsSftpPath(temp), fileSizeBytes(localNativeDeps)); + await execWindowsPowerShellJson(ssh, WINDOWS_INSTALL_NATIVE_DEPS_SCRIPT, { + temp, + staging, + destination: layout.nativeDir, + versionMarker: layout.nativeVersionMarker, + version: request.appVersion, + size: fileSizeBytes(localNativeDeps), + sha256: hashLocalFile(localNativeDeps), + }, "Unable to install Windows ADE native dependencies.", REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS); + } catch (error) { + await execWindowsPowerShellJson(ssh, String.raw` + $p = [Console]::In.ReadToEnd() | ConvertFrom-Json + Remove-Item -LiteralPath $p.temp -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $p.staging -Recurse -Force -ErrorAction SilentlyContinue + @{ ok = $true } | ConvertTo-Json -Compress + `, { temp, staging }, "Unable to clean up Windows ADE native dependency staging.").catch(() => undefined); + throw error; + } + } + nativeDepsReady = true; + } + + let ptyHostWorkerReady = false; + if (localPtyHostWorker) { + const workerSha256 = hashLocalFile(localPtyHostWorker); + const status = await execWindowsPowerShellJson<{ ready: boolean }>(ssh, String.raw` + $p = [Console]::In.ReadToEnd() | ConvertFrom-Json + $ready = (Test-Path -LiteralPath $p.path -PathType Leaf) -and ((Get-FileHash -LiteralPath $p.path -Algorithm SHA256).Hash.ToLowerInvariant() -eq ([string]$p.sha256).ToLowerInvariant()) + @{ ready = $ready } | ConvertTo-Json -Compress + `, { path: layout.ptyHostWorker, sha256: workerSha256 }, "Unable to inspect the Windows ADE PTY worker."); + if (!status.ready) { + await windowsUploadVerifiedFile({ + ssh, + localPath: localPtyHostWorker, + destination: layout.ptyHostWorker, + markerPath: layout.ptyHostWorkerSha256, + markerValue: workerSha256, + }); + } + ptyHostWorkerReady = true; + } + + if (localAgentSkillsRoot) { + const files = listLocalAgentSkillFiles(localAgentSkillsRoot); + const directorySha256 = hashAgentSkillsDirectory(files); + const status = await execWindowsPowerShellJson<{ ready: boolean }>(ssh, String.raw` + $p = [Console]::In.ReadToEnd() | ConvertFrom-Json + $ready = (Test-Path -LiteralPath $p.directory -PathType Container) -and (Test-Path -LiteralPath $p.marker -PathType Leaf) -and ([IO.File]::ReadAllText([string]$p.marker).Trim() -eq [string]$p.sha256) + @{ ready = $ready } | ConvertTo-Json -Compress + `, { directory: layout.agentSkillsDir, marker: layout.agentSkillsSha256, sha256: directorySha256 }, "Unable to inspect Windows ADE agent skills."); + if (!status.ready) { + const staging = `${layout.agentSkillsDir}.tmp-${crypto.randomBytes(8).toString("hex")}`; + await execWindowsPowerShellJson(ssh, WINDOWS_ENSURE_DIRECTORIES_SCRIPT, { directories: [staging] }, "Unable to prepare Windows ADE agent skills."); + for (const file of files) { + await windowsUploadVerifiedFile({ + ssh, + localPath: file.localPath, + destination: path.win32.join(staging, ...file.relativePath.split("/")), + }); + } + await execWindowsPowerShellJson(ssh, String.raw` + $p = [Console]::In.ReadToEnd() | ConvertFrom-Json + if (Test-Path -LiteralPath $p.destination) { Remove-Item -LiteralPath $p.destination -Recurse -Force } + Move-Item -LiteralPath $p.staging -Destination $p.destination -Force + [IO.File]::WriteAllText([string]$p.marker, [string]$p.sha256 + [Environment]::NewLine) + @{ ok = $true } | ConvertTo-Json -Compress + `, { staging, destination: layout.agentSkillsDir, marker: layout.agentSkillsSha256, sha256: directorySha256 }, "Unable to finalize Windows ADE agent skills.", REMOTE_ARTIFACT_UPLOAD_TIMEOUT_MS); + } + } + + const runtimeCommand = windowsRuntimeRpcCommand({ layout, nativeDepsReady, ptyHostWorkerReady }); + const opened = await openValidatedRuntimeClient({ + ssh, + command: runtimeCommand, + appVersion: request.appVersion, + expectedVersion: localBinary ? request.appVersion : null, + expectedLayout: layout.channelLayout, + }); + const projects = coerceProjects(await opened.client.call("projects.list", {})); + const connectedAt = Date.now(); + let pairedCredentials: DesktopPairedMachineCredentials | null = null; + const compatibilityWarnings = [...opened.initializeInfo.compatibilityWarnings]; + if (request.pairedStore && request.target.transport !== "paired") { + try { + pairedCredentials = await upgradeSshTargetToPairedCredentials({ + ssh, + layout: layout.channelLayout, + runtimeEnvPrefix: "", + binaryExpr: "", + commandOverride: windowsRuntimeRpcCommand({ layout, nativeDepsReady, ptyHostWorkerReady, pairDevice: true }), + appVersion: request.appVersion, + connectedRoute, + store: request.pairedStore, + }); + } catch (error) { + console.warn("remote_runtime.ssh_pairing_upgrade_failed", { detail: runtimeErrorMessage(error) }); + compatibilityWarnings.push("Connected, but ADE couldn't save the faster reconnect method. You can keep using this machine now and reconnect later to try again."); + } + } + const updated = request.registry.update(request.target.id, { + lastSeenArch: platform.label, + runtimeBinaryVersion: opened.initializeInfo.version ?? runtimeVersion ?? installedVersion, + lastConnectedAt: connectedAt, + routes: markRemoteTargetRouteSucceeded({ target: request.target, route: connectedRoute, nowMs: connectedAt }), + ...(pairedCredentials ? { + transport: "paired" as const, + pairedMachine: { hostIdentity: pairedCredentials.hostIdentity.deviceId, machineKey: pairedCredentials.machineKey ?? null }, + } : {}), + }); + const host = connectedRoute.hostname.includes(":") && !connectedRoute.hostname.startsWith("[") + ? `[${connectedRoute.hostname}]` + : connectedRoute.hostname; + return { + client: opened.client, + ssh, + result: { + target: updated, + arch: platform.label, + version: opened.initializeInfo.version ?? runtimeVersion, + route: { kind: "ssh", endpoint: `${host}:${connectedRoute.port ?? request.target.port ?? 22}` }, + capabilities: opened.initializeInfo.capabilities, + compatibilityWarnings, + projects, + }, + }; +} + export async function bootstrapRemoteRuntime(args: { target: RemoteRuntimeTarget; registry: RemoteTargetRegistry; @@ -1672,11 +2259,10 @@ export async function bootstrapRemoteRuntime(args: { const uploadConnectionConfig = openSshConfig ?? connectedConfig; let installDiskSpace: RemoteInstallDiskSpace | null = null; try { - const uname = await execSsh(ssh, "uname -sm"); - if (uname.code !== 0) { - throw new Error(uname.stderr.trim() || "Unable to detect remote architecture."); + const arch = await detectRemotePlatform(ssh); + if (arch.platform === "win32") { + return await bootstrapWindowsRemoteRuntime({ request: args, ssh, connectedRoute, platform: arch }); } - const arch = normalizeRemoteArch(uname.stdout.trim()); const preferredLayout = resolveRemoteRuntimeLayout(); let layout = preferredLayout; let runtimeLayoutFallbackReason: string | null = null; diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts index e3fe67359..45e139cf5 100644 --- a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.test.ts @@ -131,7 +131,7 @@ describe("runtimeDiscovery", () => { }); }); - it("keeps Windows Bonjour hosts visible but marks them unsupported", () => { + it("keeps Windows Bonjour hosts visible and connectable", () => { const discovered = discoveredRuntimeFromBonjourService({ name: "ADE Sync Build PC", host: "build-pc.local", @@ -147,8 +147,7 @@ describe("runtimeDiscovery", () => { expect(discovered).toMatchObject({ machineName: "Build PC", os: "windows", - connectable: false, - unsupportedReason: "Windows machines can't run the ADE remote runtime yet.", + connectable: true, }); }); @@ -219,7 +218,7 @@ describe("runtimeDiscovery", () => { expect(discovered[0]?.machineName).toBe("studio"); }); - it("keeps Windows peers visible but marks them unsupported", () => { + it("keeps online Windows peers visible and connectable", () => { const discovered = discoveredRuntimesFromTailscaleStatus({ Peer: { "nodekey:windows": { @@ -237,8 +236,7 @@ describe("runtimeDiscovery", () => { expect(discovered[0]).toMatchObject({ machineName: "build-pc", os: "windows", - connectable: false, - unsupportedReason: "Windows machines can't run the ADE remote runtime yet.", + connectable: true, }); }); diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts index 0e4d42378..496e73cf6 100644 --- a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts @@ -175,7 +175,6 @@ export function discoveredRuntimeFromBonjourService( parsePositiveInteger(txt.projectCount) ?? (projectIds.length > 0 ? projectIds.length : null); const os = firstNonEmpty([txt.platform]); - const isWindows = os?.toLowerCase() === "windows"; return { id: hostIdentity ? `${hostIdentity}::${serviceKey}` : serviceKey, @@ -190,10 +189,7 @@ export function discoveredRuntimeFromBonjourService( runtimeKind: firstNonEmpty([txt.runtimeKind]), runtimeVersion: firstNonEmpty([txt.runtimeVersion]), ...(os ? { os } : {}), - connectable: !isWindows, - ...(isWindows - ? { unsupportedReason: "Windows machines can't run the ADE remote runtime yet." } - : {}), + connectable: true, projectIds, projectCount, lastSeenAt: nowMs, @@ -231,8 +227,7 @@ export function discoveredRuntimesFromTailscaleStatus( const hostIdentity = trimmed(peer.ID) ?? trimmed(peerKey); const online = peer.Online === true; const os = trimmed(peer.OS); - const isWindows = os?.toLowerCase() === "windows"; - const connectable = online && !isWindows; + const connectable = online; const addresses = uniqueStrings([...tailscaleIps, dnsName]); discovered.push({ id: `tailscale:${hostIdentity ?? tailscaleAddress}`, @@ -250,9 +245,7 @@ export function discoveredRuntimesFromTailscaleStatus( connectable, ...(!connectable ? { - unsupportedReason: !online - ? "Offline" - : "Windows machines can't run the ADE remote runtime yet.", + unsupportedReason: "Offline", } : {}), projectIds: [], diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f8a907804..67425a653 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -188,7 +188,7 @@ Personal chat is an explicit machine-only variant of the typed chat family: `ade **Proof subcommands** — `ade proof capture` (alias of `screenshot`), `ade proof attach `, `ade proof record`, `ade proof launch`, `ade proof interact`, `ade proof list/status/environment/ingest`, `ade proof rm …`, `ade proof broken`, `ade proof prune [--broken]`, and `ade proof recover `. `attach` resolves relative paths from the caller's lane worktree, infers the artifact kind from the file extension, and routes through `ingest_computer_use_artifacts` with `backendStyle: "manual"`; the broker rejects non-evidence extensions and denied/escaped sources before inserting any row. Bare `proof prune` lists broken records, while `--broken` deletes them. Capture-style commands set `preferHeadless: true` on the plan so the connection layer drops to headless mode unless `--socket` is explicitly requested. Owner-aware proof subcommands accept `--owner-kind` / `--owner-id` (with `chat` and `pr` aliases) to layer an explicit owner on top of the inferred session identity. -**Bundled runtime artifacts.** Per-platform `ade-` binaries plus their native dep tarballs live under `apps/desktop/resources/runtime/`, with packaged ADE CLI resources providing the `ptyHostWorker.cjs` used by remote terminals. `release-core.yml` builds and validates the darwin/linux arm64/x64 pairs plus `ade-win32-x64.exe` and `ade-win32-x64.native.tar.gz`. Windows desktop packages include all four Darwin/Linux sidecar pairs, not just the host's Windows CLI resources, so a Windows client can bootstrap supported macOS/Linux SSH runtimes. The standalone Windows runtime, `install.ps1`, and its checksums remain behind the signed-build, public-release, and installed-update-proof gates. `bootstrapRemoteRuntime` uploads missing or hash-mismatched artifacts on first SSH connect from the desktop client; Windows as an SSH-bootstrap runtime target is still out of scope. +**Bundled runtime artifacts.** Per-platform `ade-` binaries plus their native dep tarballs live under `apps/desktop/resources/runtime/`, with packaged ADE CLI resources providing the `ptyHostWorker.cjs` used by remote terminals. `release-core.yml` builds and validates the darwin/linux arm64/x64 pairs plus `ade-win32-x64.exe` and `ade-win32-x64.native.tar.gz`. Desktop packages include the supported sidecar pairs so macOS and Windows clients can bootstrap macOS, Linux, and Windows 10 22H2/Windows 11 x64 machines over SSH. Windows bootstrap requires Windows OpenSSH Server, PowerShell 5.1 or newer, and the built-in `tar.exe`; WSL, ARM64, and Windows Server are excluded from Windows v1. The standalone Windows runtime, `install.ps1`, and its checksums remain behind the signed-build, public-release, and installed-update-proof gates. `bootstrapRemoteRuntime` uploads missing or hash-mismatched artifacts over SFTP, verifies their SHA-256 digests remotely, and starts the channel-separated runtime on first SSH connect. **Headless install + update.** A standalone runtime can be installed on a headless machine without going through the desktop installer. Remote machines reached over SSH don't need this path: `bootstrapRemoteRuntime` uploads the desktop app's bundled runtime artifacts. diff --git a/docs/development/windows-port-lane.md b/docs/development/windows-port-lane.md index 8016c259b..cbc9923af 100644 --- a/docs/development/windows-port-lane.md +++ b/docs/development/windows-port-lane.md @@ -32,6 +32,7 @@ These are the foundations that should stay merged from this lane (see also `docs | **Desktop UX** | Windows uses a hidden title bar with native window overlay/caption controls and an explicit AppUserModelID. iOS Simulator and macOS Attention Notch controls are hidden; persisted iOS sidebar state falls back to Git. App Control, built-in Browser, and proof ingestion remain available. Visible local-machine/Finder/Command-key copy is platform-neutral or platform-aware. | | **Installers** | The assisted NSIS installer is explicitly per-user and non-elevating. Its custom install step repairs the channel-aware CLI shim, current-user `PATH`, and brain startup registration; uninstall removes only the terminal shim, PATH/protocol/association/startup state owned by that installation. Stable/Beta/Alpha use distinct executable, app, and shim names. Windows packages carry all Darwin/Linux remote-runtime sidecars. Electron-builder owns `app-update.yml`; CI binds it to `${{ github.repository }}` and package smoke verifies the authority. | | **Standalone brain** | Releases build `ade-win32-x64.exe` plus a native dependency archive and checksum them with all other runtime artifacts. `install.ps1` stages and verifies both, installs the current-user PATH/service, and rolls back on failure. `ade brain start/status/doctor/update` support Windows; self-update stops the running executable before replacement and restores the previous runtime/service on failure. | +| **Remote SSH runtime** | Windows 10 22H2 and Windows 11 x64 are native SSH-bootstrap targets through Windows OpenSSH Server. Bootstrap uses encoded PowerShell plus JSON stdin, verified SFTP uploads for `ade-win32-x64.exe`, native dependencies, the PTY worker, and agent skills, then launches the channel-specific named-pipe runtime through `ade rpc --stdio`. PowerShell 5.1+ and `tar.exe` are prerequisites; WSL, ARM64, and Windows Server remain excluded from Windows v1. | | **CI/release** | `ci.yml` has a required `windows-latest` package job that builds and smokes an unsigned preview, including fresh install, repair, reinstall, PATH/startup/deep-link/file-association ownership, and uninstall. `release-core.yml` enables the signed job only with `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`; the Windows signing-secret contract is exactly `WINDOWS_CSC_LINK`, `WINDOWS_CSC_KEY_PASSWORD`, `WINDOWS_SIGNING_EXPECTED_SUBJECT`, and `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`, with a trusted RFC3161 timestamp and the approved identity required for the installer, installed app, and standalone runtime (thumbprint equality is enforced whenever a thumbprint is configured). The non-publishing run retains a checksum-covered standalone proof bundle for offline clean-host installation. While public Windows publication is disabled, a failed or skipped signed Windows test build cannot block the existing macOS release. Public Windows assets additionally require both `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` and `ADE_WINDOWS_INSTALLED_UPDATE_PROOF_APPROVED=1`; the latter attests to the mandatory clean-host, two-unpublished-version signed N-to-N+1 updater proof. | | **Sync / Tailscale** | `resolveTailscaleCliPath` (shared): macOS bundle, Windows `Program Files`\\Tailscale, then `PATH`. | @@ -55,10 +56,6 @@ Recent `main` work that is **not** inherently macOS-only but can surface path/sh - **iOS Simulator / Attention Notch** — hidden on Windows by capability. These remain macOS-only product surfaces. - **Releases** — pull-request CI may publish an unsigned Windows preview artifact for internal testing. With `ADE_WINDOWS_SIGNED_BUILD_ENABLED=1`, `release-core.yml` fails the Windows jobs unless the installer, packaged app, and standalone runtime are Authenticode signed, timestamped, and match `WINDOWS_SIGNING_EXPECTED_SUBJECT` or `WINDOWS_SIGNING_EXPECTED_THUMBPRINT`; the installer and packaged app must also share one certificate. That test job cannot block macOS publication while public Windows publication is disabled. SmartScreen reputation remains a release-engineering concern, not only app code. -- **Windows as an SSH-bootstrap target** — the standalone Windows brain is - installable and updateable, but desktop remote bootstrap still detects and - uploads only supported macOS/Linux targets. A Windows client can bootstrap - those targets and reports actionable OpenSSH Client prerequisite diagnostics. - **Docs in `AGENTS.md`** still emphasize macOS Codex/Computer Use; Windows developers should use this file + `docs/ARCHITECTURE.md` for WSL/VM dev notes if applicable. ## Engineering backlog (complete the “parity” bar) diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 49723d7a7..04ab1c0fe 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -616,7 +616,7 @@ npm --prefix apps/ade-cli run build:static -- --target --out-dir ../des ## Standalone runtime install -For headless macOS / Linux machines that can run an SSH server but have no desktop, the runtime can be installed directly from a release. Windows x64 machines can install the same standalone brain locally. Release publishing includes `install.sh`, `install.ps1`, `SHA256SUMS`, the `ade-` binaries (with `.exe` for Windows), and matching native dependency archives. SSH-reachable macOS/Linux machines can still skip the standalone installer because desktop bootstrap uploads bundled runtime artifacts on first connect; Windows remains a local standalone target, not an SSH-bootstrap target. +For headless machines that can run an SSH server but have no desktop, the runtime can be installed directly from a release. Windows 10 22H2 and Windows 11 x64 machines can install the standalone brain locally or be bootstrapped through Windows OpenSSH Server. Release publishing includes `install.sh`, `install.ps1`, `SHA256SUMS`, the `ade-` binaries (with `.exe` for Windows), and matching native dependency archives. Desktop bootstrap uploads bundled runtime artifacts on first connect, verifies size and SHA-256 through native platform tools, and launches `ade rpc --stdio` with the channel-specific ADE home. Windows SSH bootstrap requires PowerShell 5.1 or newer and `tar.exe`; WSL, ARM64, and Windows Server are not supported in Windows v1. ```bash curl -fsSL https://github.com/arul28/ADE/releases/latest/download/install.sh | sh From f385e979b6e28b9ccf8229b1896b69fe28427caf Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 13:37:05 -0400 Subject: [PATCH 08/12] fix(windows): keep the installer name identical to its updater feed entry Channel builds derived the installer file name from ${productName}, so Beta produced "ADE Beta--win-x64.exe". electron-builder writes that name to disk but rewrites latest.yml's url/path to a space-free "safe" name ("ADE-Beta-...") because its own GitHub publisher uploads under that name. release-core.yml does not use that publisher: it uploads the on-disk file with `gh release upload`, where GitHub normalizes the asset name again. A Beta publish would therefore ship a feed pointing at a file that never existed, and electron-updater would 404. Stable was unaffected only because "ADE" happens to be GitHub-safe already. Pin the artifact name to a space-free per-channel base so the built file, latest.yml, and the published asset are byte-identical on every channel, and fail validation if the name ever stops being GitHub-safe. The Stable installer pattern and the workflow globs now require a digit after "ADE-" so they select the Stable installer regardless of whether a Beta installer sits beside it. Product name, install directory, and executable name are unchanged; only the distributable file name moves. Based-on: nsxdavid/ADE#999 (cherry picked from commit 16f24f5fafaf2eb88f64f3e22caf7499184d2eca) --- .github/workflows/ci.yml | 4 +- .github/workflows/release-core.yml | 2 +- apps/desktop/package.json | 2 +- apps/desktop/scripts/run-electron-builder.mjs | 10 +++- .../scripts/validate-win-artifacts.mjs | 20 ++++++++ .../scripts/windows-package-identity.mjs | 24 ++++++++- .../scripts/windows-release-contract.test.mjs | 49 ++++++++++++++++++- 7 files changed, 102 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1984e370..ab31561ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -574,7 +574,7 @@ jobs: - name: Record Stable installer shell: pwsh run: | - $installers = @(Get-ChildItem apps/desktop/release/ADE-*-win-x64.exe -File) + $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 @@ -589,7 +589,7 @@ jobs: - name: Test Stable and Beta installed-product lifecycles shell: pwsh run: | - $betaInstallers = @(Get-ChildItem "apps/desktop/release/ADE Beta-*-win-x64.exe" -File) + $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 ` diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 0913e83dd..3ec649847 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -339,7 +339,7 @@ jobs: - name: Test installed signed Windows product lifecycle shell: pwsh run: | - $installers = @(Get-ChildItem apps/desktop/release/ADE-*-win-x64.exe -File) + $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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index eb074574b..176e61f5c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -337,7 +337,7 @@ ], "rfc3161TimeStampServer": "http://timestamp.digicert.com" }, - "artifactName": "${productName}-${version}-win-${arch}.${ext}", + "artifactName": "ADE-${version}-win-${arch}.${ext}", "extraResources": [ { "from": "scripts/windows-install-setup.ps1", diff --git a/apps/desktop/scripts/run-electron-builder.mjs b/apps/desktop/scripts/run-electron-builder.mjs index 1820cc2b4..e359979f6 100644 --- a/apps/desktop/scripts/run-electron-builder.mjs +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -2,7 +2,10 @@ import fs from "node:fs"; import path from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { resolveWindowsPackageIdentity } from "./windows-package-identity.mjs"; +import { + resolveWindowsPackageIdentity, + windowsInstallerArtifactName, +} from "./windows-package-identity.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const desktopRoot = path.resolve(scriptDir, ".."); @@ -63,6 +66,11 @@ const args = [ `--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}`), diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index 1024a64e7..ba4392931 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -9,7 +9,9 @@ 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"; @@ -279,6 +281,17 @@ function validatePreflight() { 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 @@ -863,6 +876,13 @@ 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); diff --git a/apps/desktop/scripts/windows-package-identity.mjs b/apps/desktop/scripts/windows-package-identity.mjs index 5a5956cd7..17322cacc 100644 --- a/apps/desktop/scripts/windows-package-identity.mjs +++ b/apps/desktop/scripts/windows-package-identity.mjs @@ -14,13 +14,33 @@ export function resolveWindowsPackageIdentity(rawChannel = "") { 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 escapedProductName = identity.productName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`^${escapedProductName}-.+-win-x64\\.exe$`); + 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-release-contract.test.mjs b/apps/desktop/scripts/windows-release-contract.test.mjs index c84a4545f..ef2bf9134 100644 --- a/apps/desktop/scripts/windows-release-contract.test.mjs +++ b/apps/desktop/scripts/windows-release-contract.test.mjs @@ -6,7 +6,9 @@ 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"; @@ -187,18 +189,61 @@ test("Windows packaging rejects unknown channels before electron-builder", () => }); 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", + "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", + "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-core.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", "publish-release"); const windowsSignStep = runtimeBuild.slice( From d4beb6dbbcef8b5e45e620162f50a8cd40538ccf Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 21:06:08 -0400 Subject: [PATCH 09/12] fix(windows): register one version-independent Add/Remove Programs entry The installed-product smoke asserts that each channel registers exactly one HKCU uninstall entry whose DisplayName is the product name and whose DisplayVersion carries the version. electron-builder defaults nsis.uninstallDisplayName to "${productName} ${version}", and package.json never set it, so the Stable install registered DisplayName "ADE 1.0.0-beta.1" and the smoke's `DisplayName -eq "ADE"` filter matched nothing. That step had never executed before this batch - every earlier package-win run failed at the preceding Beta build - so the contract was written but never met. Windows expects DisplayName to identify the product and DisplayVersion to carry the version, and Stable/Beta side-by-side installs are only tellable apart when each channel owns one DisplayName that does not move every release. Pin uninstallDisplayName to ${productName} and fail preflight validation if it ever drifts back to the electron-builder default. Based-on: nsxdavid/ADE#999 (cherry picked from commit 862c41d87b6ca932cc9e382c981ee41b3914b9f6) --- apps/desktop/package.json | 1 + apps/desktop/scripts/validate-win-artifacts.mjs | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 176e61f5c..9baf1fb67 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -361,6 +361,7 @@ }, "nsis": { "include": "build/installer.nsh", + "uninstallDisplayName": "${productName}", "oneClick": false, "perMachine": false, "allowElevation": false, diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index ba4392931..a48fdb44a 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -300,6 +300,18 @@ function validatePreflight() { ) { 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) { From f861f703e540b76ddf8c01b1dd2b6eb9c9399863 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 04:59:05 -0400 Subject: [PATCH 10/12] fix(windows): ship the non-AVX2 OpenCode build on x64 opencode-ai's postinstall probes IsProcessorFeaturePresent(40) and picks opencode-windows-x64-baseline on x64 CPUs without AVX2, but the packaged app never saw that decision: asarUnpack shipped only the AVX2 opencode-windows-x64 package and OPENCODE_PLATFORM_PACKAGES mapped win32/x64 to it unconditionally. A packaged build on a non-AVX2 x64 machine therefore resolved the AVX2 binary and died with an illegal instruction and no diagnostic. darwin-arm64 has no baseline variant, so the reference platform never showed it. Ship the baseline build alone rather than both. The two Windows x64 binaries are byte-for-byte the same size (141,507,976 bytes at 1.15.5), and the baseline build shows no measurable cost for how ADE drives OpenCode - it runs it as a local HTTP server, where boot time and request latency are the same within noise - so replacing the AVX2 build costs nothing in installer size and nothing in throughput, while making every x64 CPU able to execute what it resolves. Add a preflight guard so a future edit cannot reintroduce the AVX2-only package and silently reopen the crash path. Based-on: nsxdavid/ADE#999 --- apps/desktop/package.json | 2 +- apps/desktop/scripts/validate-win-artifacts.mjs | 11 +++++++++++ .../main/services/opencode/openCodeBinaryManager.ts | 10 +++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9baf1fb67..c7739d659 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -215,7 +215,7 @@ "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/**", diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index a48fdb44a..37d9e1954 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -278,6 +278,17 @@ 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"); + } if (pkg.build?.win?.icon !== "build/icon.ico") { fail("package.json build.win.icon must point to build/icon.ico"); } diff --git a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts index 28f2b0cb6..10f6200ff 100644 --- a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts +++ b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts @@ -34,7 +34,15 @@ const OPENCODE_PLATFORM_PACKAGES: Partial Date: Mon, 3 Aug 2026 04:59:36 -0400 Subject: [PATCH 11/12] fix(windows): pre-authorize the LAN sync listener in Windows Firewall Nothing in the installer ever touched the firewall, so every packaged first run raised the Windows "allow this app" prompt: the brain binds the sync host on 0.0.0.0 across 8787-8999 by design so phones on the same wifi can reach it, and advertises itself over mDNS on UDP 5353. Both listeners run inside the packaged Electron executable. Add windows-firewall-rules.ps1 and call it from customInstall and customUnInstall. It creates two inbound allow rules scoped to that one executable, to the sync port range and mDNS only, and to the private and domain profiles - public networks are left out so being reachable on an untrusted network stays a decision the user makes at the Windows prompt. The rules carry a channel plus install-path identity in their name and are deleted before being added, so reinstalling over an existing install replaces them instead of stacking duplicates, and the uninstaller can recompute and remove exactly what it created. The rules are best effort by necessity, not by choice. Windows has no per-user firewall rule store; every write needs Administrator, and this installer is deliberately per-user and non-elevating. When the script runs unelevated it makes no change and says so in the installer log rather than firing a netsh command that fails invisibly, so the prompt a user then sees has a recorded explanation behind it. Removing the prompt for the common unelevated install needs an in-app elevation step at the point the user turns sync on; that is a follow-up. Based-on: nsxdavid/ADE#999 --- apps/desktop/build/installer.nsh | 25 +++ apps/desktop/package.json | 4 + .../scripts/after-pack-runtime-fixes.cjs | 1 + .../scripts/validate-win-artifacts.mjs | 1 + .../scripts/windows-firewall-rules.ps1 | 192 ++++++++++++++++++ 5 files changed, 223 insertions(+) create mode 100644 apps/desktop/scripts/windows-firewall-rules.ps1 diff --git a/apps/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh index 3481c68aa..7752b93ed 100644 --- a/apps/desktop/build/installer.nsh +++ b/apps/desktop/build/installer.nsh @@ -30,6 +30,21 @@ ${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 @@ -40,6 +55,16 @@ ${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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c7739d659..553f930a3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -347,6 +347,10 @@ "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", diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs index 56b3b088b..8480df5c9 100644 --- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -425,6 +425,7 @@ module.exports = async function afterPack(context) { 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)}`); diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index 37d9e1954..e7965be61 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -265,6 +265,7 @@ function validatePreflight() { 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"); 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 From 4c3858044c26a929c19b0ce9c61b3eb5fae6ebde Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 13:36:31 -0400 Subject: [PATCH 12/12] fix(windows): stop shipping the unused AVX2 OpenCode build f861f703 pointed OPENCODE_PLATFORM_PACKAGES and asarUnpack at opencode-windows-x64-baseline, but two other places still decided what a Windows package actually contains, and neither moved: - afterPack's ensureOpenCodeRuntimePackages materializes the on-target OpenCode package into app.asar.unpacked from a hardcoded per-platform list, and win32 still named opencode-windows-x64. pruneUnneededRuntimePayload deletes every opencode-* directory first, so the baseline package the resolver looks for was removed and never restored, and the install shim at opencode-ai/bin/opencode.exe is pruned right after. The packaged app had no OpenCode binary on any candidate path at all. - dropping opencode-windows-x64 from asarUnpack does not stop electron-builder from copying it; it only moves it inside app.asar. The AVX2 build therefore shipped twice - 141,508,115 bytes embedded in app.asar where an exe can never be spawned, plus another 141,508,115 bytes re-copied into app.asar.unpacked by afterPack - while nothing resolved either copy. Materialize the baseline package on win32 and exclude the AVX2 package from build.files outright, which is the only lever that keeps it out of the archive. Net effect for the Windows x64 installer: one 141,508,124-byte baseline binary on the path the resolver actually probes, and 141,508,106 fewer bytes of unreachable payload. Extend the preflight guard to require the build.files exclusion, flip the packaged-artifact hygiene checks to require baseline and reject the AVX2 package, and add an app.asar index check so a future edit that drops a native OpenCode package from asarUnpack without excluding it cannot quietly bury 141 MB inside the archive again. Cover both resolver outcomes in openCodeBinaryManager tests. Based-on: nsxdavid/ADE#999 --- apps/desktop/package.json | 3 +- .../scripts/after-pack-runtime-fixes.cjs | 9 ++- .../scripts/validate-win-artifacts.mjs | 65 ++++++++++++++++++- .../opencode/openCodeBinaryManager.test.ts | 27 ++++++++ 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 553f930a3..74d6aa1b3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -185,7 +185,8 @@ "dist/**/*", "electron.cjs", "package.json", - "vendor/**/*" + "vendor/**/*", + "!node_modules/opencode-windows-x64/**" ], "asarUnpack": [ "dist/main/packagedRuntimeSmoke.cjs", diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs index 8480df5c9..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", diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index e7965be61..017b51e4f 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -204,6 +204,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; @@ -290,6 +333,15 @@ function validatePreflight() { 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"); } @@ -595,10 +647,17 @@ async function validatePackageHygiene(resourcesPath) { // The afterPack step (ensureOpenCodeRuntimePackages) now deliberately bundles // 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. - await assertPathExists(path.join(unpackedPath, "node_modules", "opencode-windows-x64"), "bundled OpenCode Windows x64 payload in Windows package"); + // 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-baseline"), "baseline OpenCode Windows x64 payload in Windows package"); + 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"); diff --git a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts index 1b439d83c..7a150cebf 100644 --- a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.test.ts @@ -141,6 +141,33 @@ describe("openCodeBinaryManager", () => { }); }); + it("resolves the packaged Windows x64 layout to the baseline package", () => { + // Mirrors what afterPack materializes for win32: the `-baseline` native + // package plus an `opencode-ai` shell whose bin/ has been pruned. The AVX2 + // `opencode-windows-x64` package is deliberately absent from the installer. + setProcessPlatform("win32"); + process.env.ADE_OPENCODE_BUNDLE_ROOT = tempRoot; + const baselinePath = path.join( + tempRoot, "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe", + ); + makeExecutable(baselinePath); + fs.mkdirSync(path.join(tempRoot, "node_modules", "opencode-ai", "bin"), { recursive: true }); + + expect(resolveOpenCodeBinary()).toEqual({ path: baselinePath, source: "bundled" }); + }); + + it("does not resolve the AVX2 opencode-windows-x64 package on win32 x64", () => { + // Guards the packaging saving: nothing in the resolver's candidate list names + // the AVX2 package, so any copy of it in the installer is unreachable weight. + setProcessPlatform("win32"); + process.env.ADE_OPENCODE_BUNDLE_ROOT = tempRoot; + const avx2Path = path.join(tempRoot, "node_modules", "opencode-windows-x64", "bin", "opencode.exe"); + makeExecutable(avx2Path); + fs.mkdirSync(path.join(tempRoot, "node_modules", "opencode-ai", "bin"), { recursive: true }); + + expect(resolveOpenCodeBinary().path).not.toBe(avx2Path); + }); + it("finds the bundled OpenCode runtime from NODE_PATH for static ADE runtimes", () => { const runtimeNodeModules = path.join(tempRoot, "ade-darwin-arm64.native", "node_modules"); process.env.NODE_PATH = runtimeNodeModules;