diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d41d0f75b..f4a175497 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -405,6 +405,54 @@ jobs: if-no-files-found: error compression-level: 0 + 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 @@ -449,6 +497,7 @@ jobs: - test-account-directory - build - build-runtime-binaries + - 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/AGENTS.md b/AGENTS.md index efa0e0507..3e72b5220 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Utilities (run when relevant, not part of the core loop): **/audit** (targeted b ## Playbooks - `docs/playbooks/ship-lane.md` — autonomous PR-to-merge driver (poll → fix → rebase → merge). Baseline `/quality` and `/test` run before it; mutation-specific commit-bound quality revalidation runs inside it. Any agent CLI can follow it directly; Claude Code invokes it via the `/ship` skill. +- `docs/playbooks/windows-signed-release.md` — maintainer handoff for taking the gated Windows x64 build through signing, clean-host and installed-update proof, draft verification, publication, and website enablement without changing the macOS or iOS release paths. ## Working norms diff --git a/WINDOWS_PORT.md b/WINDOWS_PORT.md new file mode 100644 index 000000000..a28ecffb4 --- /dev/null +++ b/WINDOWS_PORT.md @@ -0,0 +1,430 @@ +# Windows port evaluation + +## Executive summary + +ADE does not need a ground-up Windows port. Most platform foundations already +exist: Windows named pipes, PowerShell/cmd PTYs, Git for Windows resolution, +process-tree termination, Windows native provider packages, `node-pty`, a +vendored x64 `crsqlite.dll`, NSIS packaging, CLI wrappers, and extensive +artifact validation. + +The repository history confirms this. Windows foundations landed in April-May +2026 (`#186`, `#213`, and `#281`), and the release was deliberately made +macOS-only on June 12 in `#561`. However, simply uncommenting the Windows +workflow would not produce a dependable release. + +Recommended direction: + +- Target Windows 10/11 x64 using the existing per-user NSIS installer. +- Ship a bounded "Windows x64 preview" PR instead of promising complete + platform parity. +- Explicitly defer Windows ARM64, Windows as a remotely installable ADE brain, + native Windows computer use, and iOS Simulator support. + +The highest risk is the packaged background brain lifecycle, not Electron +rendering or TypeScript compilation. + +## Implementation status on `windows-native-build` + +The code changes recommended by this evaluation are now implemented on the +working branch: + +- The Windows brain runs through a per-user/channel current-user startup entry + and a BOM-marked PowerShell launcher that restores the complete resolved + runtime environment without requiring administrator access. Legacy Scheduled + Task cleanup fails closed, and runtime/desktop-bridge named pipes are isolated by canonical ADE + home, channel, and current user. Windows IPC servers explicitly retain + Node's intended-user-only named-pipe access flags; effective cross-account + access remains a clean-VM proof gate. +- Tracked CLI continuation uses structured command/argv/env descriptors on + Windows for Claude, Codex, Cursor, OpenCode, and Droid. App Control likewise + uses structured Windows launches for direct Electron/package scripts and + platform-specific shell fallbacks. Fresh provider intent is materialized on + the runtime that owns the lane, so a Windows renderer cannot send + PowerShell wrappers or Windows skill paths to a pinned macOS/Linux runtime. +- The Windows x64 package contains every supported Darwin/Linux remote-runtime + sidecar. Required `win-unpacked` package smoke validates the CLI/TUI, + ConPTY, bundled Claude/Codex/OpenCode binaries, Cursor native helpers, + Cursor/Droid SDK entry points, update authority, and a real `crsqlite.dll` + CRR mutation. The NSIS uninstaller stops and removes the Windows background + service, then removes only the terminal shim and user `PATH` entry owned by + that installation. Installing the generated NSIS package remains a separate + external gate. +- Required pull-request CI now builds an unsigned NSIS preview on + `windows-latest`. Production Windows build and public release are separately + gated; the signed path requires a pinned Authenticode identity, matching + signer for installer and `ADE.exe`, and a trusted RFC3161 timestamp. +- The updater authority follows the repository that built the package. The + source default remains upstream `arul28/ADE`, while CI passes + `ADE_RELEASE_REPOSITORY=${{ github.repository }}` for fork builds. Windows + download links and release assets remain disabled until the public gates + are explicitly enabled. +- Windows chrome, AppUserModelID, microphone-denial guidance, sync health, and + platform-aware copy/navigation are implemented. macOS-native Notch, + computer-use, and iOS Simulator actions are hidden or capability-blocked + while App Control, Browser, and proof ingestion remain available. +- The Windows developer loop now uses a per-user named pipe, invokes local + JavaScript CLI entry points instead of fragile global `.cmd` shims, strips + inherited runtime parent/idle shutdown controls, and waits for tsup's + explicit successful-build signal before starting or restarting Electron. + Runtime startup remains bounded at 30 seconds and reports an early child + exit immediately. The launcher records whether it created the detached + runtime and shuts down only that owned runtime when Electron exits or the + developer interrupts the command, so a failed or closed dev session does + not leave a polling runtime behind. +- Windows background probes and worker processes are created with hidden + console windows. This includes background-service status checks that the + desktop polls every two seconds, provider/auth/usage/Git/Tailscale probes, + runtime and PTY workers, and service install/uninstall operations. The Unix + `ps` resource sampler now reports `unsupported-platform` on Windows without + launching a process. These protections address a host-loss incident where + visible PowerShell console windows were repeatedly created and continued + after the Electron window closed. +- Windows sync-host startup now rejects stale lock files when the recorded PID + has been reused by a different executable and records process start time for + future locks. The projectless brain uses the same 8787-8999 fallback range + as project-scoped sync instead of waiting forever on 8787. This matters on + Windows hosts where Tailscale or another local service already owns 8787. + +No source blocker is currently known for the bounded Windows x64 desktop +preview. The remaining release work is external proof: clean standard-user +Windows 10/11 install/logoff/reboot/uninstall, +Stable+Beta and two-user isolation, physical-iPhone CRR/firewall testing, +provider/PTY special-character coverage, supported macOS/Linux remote +bootstrap, and signed-installer launch/relaunch/background-brain recovery. +Do not enable `ADE_WINDOWS_PUBLIC_RELEASE_ENABLED=1` or +`VITE_ADE_WINDOWS_DOWNLOAD_ENABLED=1` before those checks pass. + +Maintainers can follow +[`docs/playbooks/windows-signed-release.md`](docs/playbooks/windows-signed-release.md) +for signing, draft-release verification, publication, +and website-enable procedure. + +Four source follow-ups are explicitly outside this preview boundary: +`ade brain update` continues to reject Windows because Windows as a standalone +remote brain is deferred; `ade doctor` does not yet discover an installed +Windows desktop version. None is required to install and run the local Windows +desktop preview, but each should be resolved before calling Windows a +first-class remote-brain/operations platform. + +## Current readiness + +| Area | Current state | Required work | +| --- | --- | --- | +| Electron/NSIS packaging | Required unsigned PR package job, owned-integration uninstall cleanup, and signed release path are implemented | Clean-VM installer proof | +| Native dependencies | `win-unpacked` smoke loads ConPTY/provider payloads and performs a real `crsqlite.dll` CRR mutation | Repeat from an installed signed build | +| Projects, lanes, Git, files | Windows-aware paths, Git, and junction code exist | Clean-VM functional testing | +| Terminal/PTY | Structured Windows launch/resume, runtime-host materialization, and taskkill cleanup are implemented | Installed provider/ConPTY matrix | +| Background brain | No-admin per-user/channel startup launcher and NSIS uninstall cleanup are implemented | Logoff/reboot/update/uninstall proof | +| Updater | Fork authority and fail-closed signing/publication gates are implemented | Validate automatic updating after two signed Windows releases exist | +| Windows developer loop | Per-user runtime pipe, successful-build-gated Electron launch, hidden background probes, and owned-runtime cleanup are implemented and host-tested | Repeat from a clean clone | +| Sync/iPhone pairing | Intended to work | CRR roundtrip and firewall testing | +| Built-in browser/proof ingest | Mostly platform-neutral | Windows Hello, download, and security testing | +| Native computer use | macOS-only by design; capability-gated on Windows | Separate native Windows project | +| iOS Simulator/Xcode Preview | macOS-only and hidden on Windows | No Windows work required | +| Windows remote brain host | Explicitly rejected | Separate project | +| Windows ARM64 | Native payloads incomplete | Separate project | + +The repository's own +[Windows port document](docs/development/windows-port-lane.md#already-in-this-branch-do-not-re-implement) +accurately lists the foundations, but its release claims are stale. + +## Original release-blocking findings + +The sections below preserve the static-evaluation rationale that shaped the +implementation. Each release-blocking source finding below has an +implementation on this branch; effective named-pipe access, clean-VM +login-startup behavior, and signed-update behavior still require the +external proof gates above. + +### 1. The scheduled background brain drops required environment variables + +This is the most serious defect. + +The service command carries `ELECTRON_RUN_AS_NODE=1`, `NODE_PATH`, channel, ADE +home, and runtime configuration in +[`common.ts`](apps/ade-cli/src/serviceManager/common.ts). However, +`renderWindowsCommand()` serializes only the executable and arguments. + +The resulting task registers roughly: + +```text +ADE.exe cli.cjs serve +``` + +without `ELECTRON_RUN_AS_NODE=1`. On a clean machine this can reopen the +Electron GUI instead of starting the CLI brain. Because ADE expects the service +to own the primary runtime pipe, this can leave the desktop without its normal +synchronized runtime. + +The PR should install a dedicated service launcher or safely serialize all +required environment variables, then prove install, start, logoff/logon, +update, and uninstall on a clean machine without Node installed. + +### 2. Background-service registration must not require administrator access + +Windows Task Scheduler rejects task creation from a standard user. ADE's +per-user installer must not require elevation merely to start its background +runtime at login. + +The implementation now writes a channel- and user-qualified value under the +current user's normal Windows startup registry key. A hidden PowerShell +supervisor starts the packaged runtime, records its process identity, and lets +status and uninstall stop only that ADE-owned process tree. Old Scheduled Task +registrations are removed during migration. + +### 3. The Windows package cannot satisfy its own validator + +The Windows validator requires Darwin and Linux x64/arm64 remote-runtime +sidecars in +[`validate-win-artifacts.mjs`](apps/desktop/scripts/validate-win-artifacts.mjs), +but [`package.json`](apps/desktop/package.json) copies only the Darwin +artifacts. + +A re-enabled `dist:win` should therefore fail post-package validation. + +The product decision is either: + +- Include all four sidecar pairs so Windows can bootstrap existing macOS/Linux + remote runtimes; or +- Reduce the Windows remote-bootstrap contract, gate the feature, and update + the validator accordingly. + +Including everything is simpler for a first preview but increases installer +size. On-demand, checksummed sidecar downloads would be cleaner later. + +### 4. Provider resume and App Control commands still contain POSIX syntax + +Fresh provider launches are mostly structured and Windows-aware. Resume and +fallback paths frequently generate shell strings instead. + +Examples include OpenCode environment assignments and Droid resume commands in +[`cliLaunch.ts`](apps/desktop/src/shared/cliLaunch.ts). App Control's +package-script rewrite emits `PATH=:$PATH` and POSIX quoting in +[`appControlLaunchCommand.ts`](apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts), +even though the command is later typed into PowerShell or cmd. + +These paths will fail with some configurations and paths containing spaces, +quotes, `$`, `%`, `&`, or backticks. + +The durable fix is a structured invocation contract: + +```ts +{ + command, + args, + env, + displayCommand, +} +``` + +Shell text should remain only for commands that genuinely require a shell, +with separate PowerShell and cmd quoting. + +### 5. Windows named-pipe identity is not sufficiently isolated + +The machine pipe name is derived only from the basename of ADE home in +[`machineLayout.ts`](apps/ade-cli/src/services/projects/machineLayout.ts). The +default `.ade` therefore produces the same global named-pipe name for every +Windows user. + +The PR should derive pipe names from the canonical ADE home, channel, and +current user SID/hash, and verify that the pipe ACL is limited to the intended +user. Test two Windows users and Stable/Beta side by side. + +### 6. Release and update configuration is disabled or points at upstream + +The Windows build, download, validation, and upload blocks are commented out +in [`.github/workflows/release-core.yml`](.github/workflows/release-core.yml). +There is also no Windows runner in normal PR CI. + +At evaluation time, the packaged updater was hardcoded to upstream +`arul28/ADE` in: + +- [`apps/desktop/package.json`](apps/desktop/package.json) +- [`apps/desktop/resources/app-update.yml`](apps/desktop/resources/app-update.yml) +- [`autoUpdateService.ts`](apps/desktop/src/main/services/updates/autoUpdateService.ts) + +A Windows build published by another fork would check upstream for updates, +where the corresponding Windows artifacts might not exist. + +The distribution repository should be build metadata generated from +`github.repository`. The production `setFeedURL` override should be removed or +centralized. Electron-builder recommends using its generated `app-update.yml`; +its NSIS target already supports Windows auto-update and `latest.yml` +metadata. See the +[electron-builder auto-update documentation](https://www.electron.build/docs/features/auto-update/). + +### 7. Public signing currently fails open + +The existing configuration supports Authenticode, but missing secrets result +in an unsigned installer. The validator only checks signatures when an opt-in +flag is set. + +Recommended policy: + +- PR CI may build an unsigned artifact. +- Release CI must fail if signing is unavailable. +- Verify the installer and installed `ADE.exe`, publisher identity, and RFC + 3161 timestamp. +- Publish the installer, blockmap, and `latest.yml` atomically. +- Use Microsoft Artifact Signing or a stable organizational Authenticode + certificate. + +Electron-builder exposes `forceCodeSigning` specifically to prevent silently +unsigned production builds. See the +[electron-builder signing documentation](https://www.electron.build/docs/features/code-signing/). + +Signing will not automatically eliminate every early SmartScreen prompt. +Microsoft notes that even valid OV/EV-signed applications can be classified as +unrecognized until publisher/file reputation develops; unsigned releases must +rebuild reputation for every version. See +[Microsoft's SmartScreen guidance](https://learn.microsoft.com/en-us/windows/apps/package-and-deploy/smartscreen-reputation). + +## Product and UX work + +The build PR should also include a focused platform pass: + +- Make the Windows title bar explicit. + [`main.ts`](apps/desktop/src/main/main.ts) unconditionally uses + `hiddenInset`, macOS traffic-light positioning, and a renderer header with + 80 px of left padding. Verify caption buttons, dragging, double-click + maximize, Snap Layouts, and DPI scaling. +- Hide or clearly disable iOS Simulator, Xcode Preview, native Notch, and local + OS computer-use actions. +- Keep browser/App Control capture and proof-file ingestion enabled where + supported. +- Replace visible "This Mac", "Reveal in Finder", `Command` key, and macOS + Keychain wording with platform-aware labels. Preserve the internal + `this-mac` identifier because it is a protocol/persistence invariant. +- Update the website. + [`DownloadPage.tsx`](apps/web/src/app/pages/DownloadPage.tsx) currently says + Windows installers are not published. +- Add Windows-specific microphone denial guidance; the current flow treats + non-macOS access as automatically granted. +- Add a Windows sync-health surface. A missing or unloadable `crsqlite.dll` + currently degrades sync primarily through logs. +- Test Windows Defender Firewall behavior for LAN phone pairing and provide + actionable relay/Tailscale guidance. +- Add `setAppUserModelId` if packaged toast identity proves unreliable. + +The supported OS floor should be Windows 10/11 x64. ADE uses Electron 41, +while Electron 23 and newer require Windows 10 or later. See +[Electron platform support](https://www.electronjs.org/docs/latest/breaking-changes). + +## Recommended PR boundary + +A reviewable first submission should be titled along the lines of +"Add Windows x64 preview build" and contain the following work. + +### 1. Runtime correctness + +- Fix the scheduled-task environment, channel naming, and locale-safe status. +- Use user/channel-scoped named pipes. +- Introduce structured provider resume commands. +- Fix App Control's Windows launch handling. +- Generate sync singleton recovery commands that do not suggest `launchctl` + or `/bin/kill`. + +### 2. Packaging and CI + +- Resolve the remote-sidecar mismatch. +- Add `windows-latest` PR packaging and smoke validation. +- Load `crsqlite.dll` and perform a minimal CRR operation during packaged + smoke. +- Probe the bundled CLI/TUI, PTY, and provider executables. +- Keep the target x64-only. + +### 3. Release and updates + +- Parameterize the fork's release authority. +- Restore the release/publish workflow. +- Fail closed on production signing. +- Validate installed N-to-N+1 signed updating after two releases exist. + +### 4. Platform UX and documentation + +- Add an explicit Windows title bar and capability-driven navigation. +- Use neutral copy and platform-aware shortcuts. +- Add Windows download and analytics links. +- Correct stale architecture and Windows-port documentation. + +Public download enablement should remain gated until the signed installer +passes the clean-host checks. If certificate provisioning is not ready, the PR +can still produce an unsigned internal CI artifact while leaving public +publishing disabled. + +## Merge gates + +At minimum: + +- Test Windows 10 22H2 and Windows 11 x64 clean standard-user VMs. +- Install without Node or administrator rights. +- Verify first launch, app restart, logoff/logon, and uninstall/reinstall. +- Install Stable and Beta simultaneously. +- Open/create a project; create/delete a lane; exercise worktree, junction, + commit, rebase, and conflict flows. +- Exercise PowerShell and cmd PTYs: Unicode, resize, Ctrl+C, cancellation, and + child-tree cleanup. +- Test fresh launch and resume for Claude, Codex, Cursor, Droid, and OpenCode. +- Test paths and prompts containing spaces, Unicode, quotes, `$`, `%`, and + `&`. +- Load packaged `crsqlite.dll` and complete a bidirectional Windows + desktop-to-physical-iPhone CRR sync. +- Use the Windows desktop to control an existing macOS/Linux remote runtime. +- Exercise the built-in browser, downloads, proof ingest, and App Control CDP + capture. +- Test `ade://` cold/hot deep links, file associations, the PATH wrapper, and + uninstall cleanup. +- Test DPI at 100/125/150/200 percent, multiple monitors, Snap Layouts, high + contrast, and keyboard navigation. + +## Explicit follow-ups + +These should not block the first Windows desktop build: + +- Windows as a remotely installable ADE brain. +- Native Windows computer use using Windows Graphics Capture/UI Automation. +- Signed N-to-N+1 automatic-update testing, including cache/retry/relaunch, + scheduled-task repair, data preservation, and rejection of tampered or + incorrectly signed updates. +- Windows ARM64 after all native/provider payloads are available. +- Windows resource telemetry and general orphan-agent recovery. + +## Readiness and uncertainty + +- The source and CI support a credible internal Windows x64 preview. +- Public Windows x64 readiness requires upstream signing configuration and the + external proof gates below. +- Largest uncertainty: installed, signed runtime behavior across clean Windows + hosts and updates. + +The initial assessment was a read-only static evaluation. Implementation and +targeted automated validation have since been completed on this branch. +A full local NSIS package still requires the CI-produced Darwin/Linux runtime +sidecars; the required Windows CI job materializes them before packaging. +No claim is made here that the external clean-VM checks or automatic-update +follow-up have passed. + +## Automated validation observed + +The source implementation was validated on Windows with: + +- A bounded no-GUI lifecycle proof that started an isolated hidden runtime + while this host's Tailscale service owned port 8787, connected over its + named pipe, requested graceful shutdown, and confirmed that the pipe was + released. +- Desktop typecheck, lint, build, documentation validation, web typecheck and + build. +- The required Windows release contract, updater, packaging-smoke, + CR-SQLite, ConPTY, App Control, microphone, window-chrome, preload, sync UI, + provider-launch, and platform-copy focused suites. +- ADE CLI typecheck/build, 328 CLI tests, 59 service-manager tests, and 1,045 + TUI tests. +- `git diff --check`. + +The legacy full test suites still contain Windows-host baseline failures in +POSIX-only fixtures, Unix-socket browser tests, chmod assertions, and several +SQLite teardown races. Focused Windows production-path tests are green, but +those baseline failures should be cleaned up in follow-up work so the entire +local suite is signal-bearing on Windows. diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index d95ca1361..b6b08e602 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -93,7 +93,7 @@ The ADE brain runs as a per-user login service. The implementations live in `src | --- | --- | --- | | macOS | launchd `LaunchAgent` | `~/Library/LaunchAgents/com.ade.runtime.plist` | | Linux | `systemctl --user` | `~/.config/systemd/user/.service` | -| Windows | `schtasks.exe ONLOGON` | scheduled task `ADE Runtime` | +| Windows | Current-user startup entry | `HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run` | The default service label is `com.ade.runtime`; channel builds override it via `ADE_PACKAGE_CHANNEL=alpha|beta` (`com.ade.runtime.alpha`, `com.ade.runtime.beta`). `ADE_RUNTIME_SERVICE_NAME` overrides the label outright and is used for both launchd and systemd unit names. macOS writes `launchd.{out,err}.log` under `ADE_HOME/runtime/`. diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 948835994..02f887df9 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -202,6 +202,7 @@ function resolveExecutableOnPath(command: string, env: NodeJS.ProcessEnv = proce encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); if (result.status !== 0 || typeof result.stdout !== "string") return null; const first = result.stdout @@ -3493,6 +3494,7 @@ async function runTool(args: { const result = spawnSync(command, commandArgs, { cwd: runtime.projectRoot, encoding: "utf8", + windowsHide: true, env: { ...process.env, ...(options?.env ?? {}), diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 0be15f9e4..d1565fa26 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -27,6 +27,7 @@ import { resolveSnoozeUntilIso, renderLaneGraph, resolveAdeCodeModulePath, + resolveWindowsDesktopExecutable, resolveRoots, runCli, startHeadlessRpcSocketServer, @@ -45,6 +46,7 @@ import { import { generateRpcAuthToken } from "./rpcAuth"; import { JsonRpcClient } from "./tuiClient/jsonRpcClient"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { localIpcListenOptions } from "./services/runtime/localIpcListenOptions"; type ResolveRootsOptions = Parameters[0]; @@ -474,7 +476,7 @@ describe("ADE CLI", () => { "laneId=lane-1", ]); - expect(parsed.options.projectRoot).toBe("/tmp/project"); + expect(parsed.options.projectRoot).toBe(path.resolve("/tmp/project")); expect(parsed.options.role).toBe("cto"); expect(parsed.command).toEqual([ "actions", @@ -536,7 +538,7 @@ describe("ADE CLI", () => { "code", "--print-state", ]); - expect(parsed.options.projectRoot).toBe("/tmp/project"); + expect(parsed.options.projectRoot).toBe(path.resolve("/tmp/project")); expect(parsed.command).toEqual(["code", "--print-state"]); const plan = buildCliPlan(parsed.command); @@ -788,14 +790,32 @@ describe("ADE CLI", () => { }, ); - it("returns null for a named-pipe socket path (desktop path; no dir/chmod)", async () => { - // isAdeRuntimeNamedPipePath matches by string prefix, so this exercises the - // named-pipe early-return branch on any platform without touching the fs. + it("declares intended-user-only access when listening on a Windows named pipe", () => { + expect(localIpcListenOptions("\\\\.\\pipe\\ade-headless-security-test")).toEqual({ + path: "\\\\.\\pipe\\ade-headless-security-test", + readableAll: false, + writableAll: false, + }); + expect(localIpcListenOptions("/tmp/ade.sock")).toBe("/tmp/ade.sock"); + }); + + (process.platform === "win32" ? it : it.skip)("hosts headless RPC on a Windows named pipe", async () => { + const socketPath = `\\\\.\\pipe\\ade-headless-${process.pid}-${Date.now()}`; const stop = await startHeadlessRpcSocketServer({ - socketPath: "//./pipe/ade-headless-named-pipe-test", + socketPath, createHandler: () => (async () => ({})) as never, }); - expect(stop).toBeNull(); + try { + expect(stop).not.toBeNull(); + const client = await JsonRpcClient.connect(socketPath); + try { + await expect(client.request("ping")).resolves.toEqual({}); + } finally { + client.close(); + } + } finally { + stop?.(); + } }); it("requires the per-boot bearer token on the headless TCP RPC listener", async () => { @@ -882,6 +902,9 @@ describe("ADE CLI", () => { expect(shouldBlockManualMachineRuntimeSpawn("tcp://127.0.0.1:9999", { ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", })).toBe(false); + expect(shouldBlockManualMachineRuntimeSpawn("\\\\.\\pipe\\ade-runtime-stable-test", { + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", + })).toBe(true); expect(shouldBlockManualMachineRuntimeSpawn(path.join(os.tmpdir(), "ade-code-test", "ade.sock"), { ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", })).toBe(false); @@ -5862,6 +5885,26 @@ describe("ADE CLI", () => { expect(shouldAttemptDesktopSocketConnection("//./pipe/ade-123")).toBe(true); }); + it("finds the Windows desktop executable beside a packaged CLI resource", () => { + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-windows-desktop-")); + const cliEntry = path.join(installRoot, "resources", "ade-cli", "cli.cjs"); + const appPath = path.join(installRoot, "ADE.exe"); + fs.mkdirSync(path.dirname(cliEntry), { recursive: true }); + fs.writeFileSync(cliEntry, ""); + fs.writeFileSync(appPath, ""); + + try { + expect(resolveWindowsDesktopExecutable({ + appName: "ADE", + env: {}, + execPath: path.join(installRoot, "node.exe"), + entryPath: cliEntry, + })).toBe(appPath); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }); + it("renders a compact lane graph", () => { const graph = renderLaneGraph({ lanes: [ @@ -6018,7 +6061,7 @@ describe("ADE CLI", () => { kind: "screenshot", title: "Checkout complete", description: "Checkout complete", - path: "/tmp/done.png", + path: path.resolve("/tmp/done.png"), }, ], }, @@ -6777,8 +6820,8 @@ describe("ADE CLI", () => { projectRoot: null, workspaceRoot: null, }); - expect(roots.projectRoot).toBe("/explicit/project-root"); - expect(roots.workspaceRoot).toBe("/explicit/project-root"); + expect(roots.projectRoot).toBe(path.resolve("/explicit/project-root")); + expect(roots.workspaceRoot).toBe(path.resolve("/explicit/project-root")); } finally { if (prevProject === undefined) delete process.env.ADE_PROJECT_ROOT; else process.env.ADE_PROJECT_ROOT = prevProject; @@ -6819,8 +6862,8 @@ describe("ADE CLI", () => { projectRoot: null, workspaceRoot: null, }); - expect(roots.projectRoot).toBe("/explicit/project-root"); - expect(roots.workspaceRoot).toBe("/explicit/workspace-root"); + expect(roots.projectRoot).toBe(path.resolve("/explicit/project-root")); + expect(roots.workspaceRoot).toBe(path.resolve("/explicit/workspace-root")); } finally { if (prevProject === undefined) delete process.env.ADE_PROJECT_ROOT; else process.env.ADE_PROJECT_ROOT = prevProject; diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index c7065c43a..2c8d080f5 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -124,6 +124,7 @@ import { snoozeWakeLabel } from "../../desktop/src/renderer/lib/sessionSnooze"; import type { AdeRuntime } from "./bootstrap"; import { cleanupLegacyBundledAdeSkillsForCli } from "./bootstrap"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { localIpcListenOptions } from "./services/runtime/localIpcListenOptions"; import type { AccountMachinePublisherService } from "./services/account/accountMachinePublisherService"; import type { SyncHostSingletonLease } from "./services/sync/syncHostSingleton"; import { @@ -131,7 +132,10 @@ import { syncAccountAnalyticsIdentity, } from "./services/account/accountAuthService"; import { getSharedAccountAuthService } from "./services/account/sharedAccountAuthService"; -import { DEFAULT_SYNC_HOST_PORT } from "./services/sync/syncProtocol"; +import { + DEFAULT_SYNC_HOST_PORT, + SYNC_HOST_MAX_PORT, +} from "./services/sync/syncProtocol"; import { runAdeCodeRemote, takeAdeCodeRemoteArgs, @@ -538,6 +542,7 @@ function maybeRunBuiltCliFallback( cwd: CLI_PACKAGE_ROOT, env: process.env, encoding: "utf8", + windowsHide: true, }); if (buildResult.error || buildResult.status !== 0 || !isBuiltCliFresh()) { error.details.nextAction = @@ -557,6 +562,7 @@ function maybeRunBuiltCliFallback( [SOURCE_FALLBACK_ENV]: "1", }, encoding: "utf8", + windowsHide: true, }); if (rerun.error) { error.details.nextAction = @@ -1126,7 +1132,7 @@ const HELP_BY_COMMAND: Record = { and explicit remote addresses continue to work while signed out. $ ade machines list --text - $ ade machines rename "Build Mac" + $ ade machines rename "Build workstation" $ ade machines rename --clear $ ade machines connect $ ade machines connect --project @@ -1196,7 +1202,7 @@ const HELP_BY_COMMAND: Record = { $ ade desktop open Flags: - --app-name macOS app name to open. Defaults to ADE, ADE Beta, + --app-name Installed app name to open. Defaults to ADE, ADE Beta, or ADE Alpha based on the installed CLI wrapper. `, github: `${ADE_BANNER} @@ -2857,6 +2863,7 @@ function detectUnmergedLaneCreateNudge( cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }), ): string | null { const cwd = args.cwd ?? process.cwd(); @@ -12589,6 +12596,7 @@ function findProjectRoots(startDir: string): { cwd: startDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); const gitRoot = git.status === 0 ? git.stdout.trim() : ""; const fallback = gitRoot ? path.resolve(gitRoot) : path.resolve(startDir); @@ -12625,6 +12633,7 @@ function commandExists(command: string): boolean { const result = spawnSync(lookupCommand, [command], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); return result.status === 0 && result.stdout.trim().length > 0; } @@ -12763,6 +12772,7 @@ function runLocalCommand( encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000, + windowsHide: true, }); return { ok: result.status === 0, @@ -13685,12 +13695,14 @@ async function startHeadlessRpcSocketServer(args: { createHandler: () => JsonRpcHandler & { dispose?: () => void }; }): Promise<(() => void) | null> { if ( - isAdeRuntimeNamedPipePath(args.socketPath) || - fs.existsSync(args.socketPath) + !isAdeRuntimeNamedPipePath(args.socketPath) + && fs.existsSync(args.socketPath) ) { return null; } - fs.mkdirSync(path.dirname(args.socketPath), { recursive: true, mode: 0o700 }); + if (!isAdeRuntimeNamedPipePath(args.socketPath)) { + fs.mkdirSync(path.dirname(args.socketPath), { recursive: true, mode: 0o700 }); + } const serverState = createHeadlessRpcServer(args.createHandler); const { server } = serverState; @@ -13705,7 +13717,7 @@ async function startHeadlessRpcSocketServer(args: { }; server.once("listening", handleListening); server.once("error", handleError); - server.listen(args.socketPath); + server.listen(localIpcListenOptions(args.socketPath)); }); if (!isAdeRuntimeNamedPipePath(args.socketPath)) { @@ -14950,8 +14962,11 @@ function shouldRepairMachineRuntimeServiceBeforeSpawn( return !socketPathOverride?.trim() && process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL !== "1" && isPackagedElectronCliRuntime() - && !socketPath.startsWith("tcp://") - && !isAdeRuntimeNamedPipePath(socketPath) + && isServiceManagedMachineRuntimeSocket(socketPath); +} + +function isServiceManagedMachineRuntimeSocket(socketPath: string): boolean { + return !socketPath.startsWith("tcp://") && !isEphemeralRuntimeSocketPath(socketPath); } @@ -14960,9 +14975,7 @@ export function shouldBlockManualMachineRuntimeSpawn( env: NodeJS.ProcessEnv = process.env, ): boolean { return env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL === "1" - && !socketPath.startsWith("tcp://") - && !isAdeRuntimeNamedPipePath(socketPath) - && !isEphemeralRuntimeSocketPath(socketPath); + && isServiceManagedMachineRuntimeSocket(socketPath); } function manualMachineRuntimeSpawnBlockedError(socketPath: string): Error { @@ -15083,6 +15096,7 @@ async function spawnMachineRuntimeDaemon( detached: true, stdio: "ignore", env, + windowsHide: true, }); child.once("error", () => {}); if (child.pid != null) recordRuntimeSpawn(socketPath, child.pid); @@ -15509,6 +15523,103 @@ async function runBrainCommand( ); } +export function resolveWindowsDesktopExecutable(args: { + appName: string; + env?: NodeJS.ProcessEnv; + execPath?: string; + entryPath?: string | null; +}): string | null { + const env = args.env ?? process.env; + const execPath = args.execPath ?? process.execPath; + const entryPath = args.entryPath ?? process.argv[1] ?? null; + const requestedName = path.basename(args.appName.trim()) || "ADE"; + const appBaseName = requestedName.toLowerCase().endsWith(".exe") + ? requestedName.slice(0, -4) + : requestedName; + const executableName = `${appBaseName}.exe`; + const execBaseName = path.basename(execPath); + const currentExecutableIsAde = + execBaseName.toLowerCase() === executableName.toLowerCase() + || /^ade(?: beta| alpha)?\.exe$/i.test(execBaseName); + const candidates: Array = [ + env.ADE_DESKTOP_APP_PATH?.trim() || null, + currentExecutableIsAde + ? execPath + : null, + entryPath + ? path.resolve(path.dirname(entryPath), "..", "..", executableName) + : null, + entryPath && executableName.toLowerCase() !== "ade.exe" + ? path.resolve(path.dirname(entryPath), "..", "..", "ADE.exe") + : null, + env.LOCALAPPDATA + ? path.join(env.LOCALAPPDATA, "Programs", appBaseName, executableName) + : null, + env.PROGRAMFILES + ? path.join(env.PROGRAMFILES, appBaseName, executableName) + : null, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + const resolved = path.resolve(candidate); + if (fs.existsSync(resolved)) return resolved; + } + return null; +} + +async function launchWindowsDesktopApp( + executablePath: string, + appName: string, +): Promise> { + const env = { ...process.env }; + // The installed CLI wrapper runs ADE.exe as Node. Carrying this flag into + // the child would launch another CLI process instead of the desktop UI. + delete env.ELECTRON_RUN_AS_NODE; + return await new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn(executablePath, [], { + detached: true, + stdio: "ignore", + env, + windowsHide: true, + }); + } catch (error) { + resolve({ + ok: false, + platform: process.platform, + appName, + path: executablePath, + message: error instanceof Error ? error.message : String(error), + }); + return; + } + let settled = false; + const finish = (result: Record): void => { + if (settled) return; + settled = true; + resolve(result); + }; + child.once("error", (error) => finish({ + ok: false, + platform: process.platform, + appName, + path: executablePath, + message: error.message, + })); + child.once("spawn", () => { + child.unref(); + finish({ + ok: true, + platform: process.platform, + appName, + path: executablePath, + message: `Opened ${appName}.`, + }); + }); + }); +} + async function runDesktopCommand(rest: string[]): Promise { const args = [...rest]; const sub = firstPositional(args) ?? "open"; @@ -15534,12 +15645,26 @@ async function runDesktopCommand(rest: string[]): Promise { }; } + if (process.platform === "win32") { + const executablePath = resolveWindowsDesktopExecutable({ appName }); + if (!executablePath) { + return { + ok: false, + platform: process.platform, + appName, + message: + `Unable to find the installed ${appName} executable. Reinstall ADE or set ADE_DESKTOP_APP_PATH.`, + }; + } + return await launchWindowsDesktopApp(executablePath, appName); + } + return { ok: false, platform: process.platform, appName, message: - "Launching ADE desktop from the CLI is currently supported on macOS.", + "Launching ADE desktop from the CLI is currently supported on macOS and Windows.", }; } @@ -15640,7 +15765,9 @@ async function runServe( const { getRuntimeServiceStatus } = await import("./serviceManager"); return getRuntimeServiceStatus(); } - boundLaunchdLogs(path.dirname(lastFailurePathForMachine())); + if (process.platform === "darwin") { + boundLaunchdLogs(path.dirname(lastFailurePathForMachine())); + } const previousFailure = readLastFailure({ kind: "machine" }); const startupBackoffMs = computeStartupBackoffMs(previousFailure, Date.now()); if (startupBackoffMs > 0 && previousFailure) { @@ -16177,7 +16304,12 @@ async function runServe( // brain out. const { acquireSyncHostSingleton } = await import("./services/sync/syncHostSingleton"); brainSyncHostLease ??= acquireSyncHostSingleton({ projectRoot: null }); - const listenerPort = await sharedSyncListener.ensureListening([DEFAULT_SYNC_HOST_PORT]); + const listenerPort = await sharedSyncListener.ensureListening( + Array.from( + { length: SYNC_HOST_MAX_PORT - DEFAULT_SYNC_HOST_PORT + 1 }, + (_, index) => DEFAULT_SYNC_HOST_PORT + index, + ), + ); brainSyncHostLease.updatePort(listenerPort); } else if (activeScope && brainSyncHostLease) { // A scope took over hosting and holds its own lease; drop the @@ -16253,7 +16385,7 @@ async function runServe( server.once("listening", handleListening); server.once("error", handleError); if (typeof target === "string") { - server.listen(target); + server.listen(localIpcListenOptions(target)); } else { server.listen(target.port, target.host); } 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/ade-cli/src/commands/deeplinks.ts b/apps/ade-cli/src/commands/deeplinks.ts index 8a16c25d3..4c29cb719 100644 --- a/apps/ade-cli/src/commands/deeplinks.ts +++ b/apps/ade-cli/src/commands/deeplinks.ts @@ -196,7 +196,11 @@ export function openUrlViaOs(url: string): { failed: boolean; message: string } args = [url]; } try { - const r = spawnSync(cmd, args, { stdio: "ignore", timeout: 10_000 }); + const r = spawnSync(cmd, args, { + stdio: "ignore", + timeout: 10_000, + windowsHide: true, + }); if (r.error) return { failed: true, message: r.error.message }; if (r.signal) return { failed: true, message: `${cmd} exited with signal ${r.signal}` }; if (typeof r.status === "number" && r.status !== 0) { diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 765d93806..92b872660 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -361,6 +361,7 @@ function runCommandAsync( encoding: "utf8", timeout: options.timeoutMs, maxBuffer: options.maxBuffer ?? 10 * 1024 * 1024, + windowsHide: true, }, (error, stdout, stderr) => { resolve({ @@ -390,6 +391,7 @@ function ghAuthToken(): Pick 0) { diff --git a/apps/ade-cli/src/lib/clipboard.ts b/apps/ade-cli/src/lib/clipboard.ts index dd6efa1dd..1a76f9de7 100644 --- a/apps/ade-cli/src/lib/clipboard.ts +++ b/apps/ade-cli/src/lib/clipboard.ts @@ -13,7 +13,7 @@ export type CopyToClipboardOptions = { * Test seam: override the spawn function. The override must return the * same shape as `spawnSync` (status + error). Defaults to `spawnSync`. */ - spawn?: (cmd: string, args: string[], options: { input: string }) => { + spawn?: (cmd: string, args: string[], options: { input: string; windowsHide?: boolean }) => { error?: Error; status?: number | null; }; @@ -50,7 +50,7 @@ export function copyToClipboard(text: string, options: CopyToClipboardOptions = return false; } } - const r = spawn(cmd, args, { input: text }); + const r = spawn(cmd, args, { input: text, windowsHide: true }); if (r.error || (typeof r.status === "number" && r.status !== 0)) return false; return true; } @@ -58,6 +58,7 @@ export function copyToClipboard(text: string, options: CopyToClipboardOptions = function defaultCommandExists(cmd: string): boolean { const r = spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore", + windowsHide: true, }); return !r.error && r.status === 0; } diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index ed836a583..a94b15b95 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -413,6 +413,7 @@ function resolveRemoteProjectIconInWorker( killSignal: "SIGKILL", maxBuffer: REMOTE_ICON_MAX_DATA_URL_BYTES + 16 * 1024, encoding: "utf8", + windowsHide: true, }, (error, stdout) => { if (error) { diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index 1255d4b9e..c91d7ebe0 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import { createHash } from "node:crypto"; +import { spawnSync as spawnChildSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +10,7 @@ import { isStaleChannelServeCommandLine, renderCommand, renderWindowsCommand, + renderWindowsServiceLauncher, resolveAdeServeCommand, type AdeServiceCommand, type ServiceManagerProcessResult, @@ -26,14 +28,21 @@ import { installSystemdService, renderSystemdEnvironment, renderSystemdUnit, ser import { buildWindowsCreateTaskArgs, buildWindowsDeleteTaskArgs, + buildWindowsEndTaskArgs, buildWindowsQueryTaskArgs, + buildWindowsRunKeyAddArgs, + buildWindowsRunKeyDeleteArgs, + buildWindowsRunKeyQueryArgs, buildWindowsRunTaskArgs, + buildWindowsStartLauncherArgs, + getWindowsServiceStatus, installWindowsService, - isSchtasksOutputRunning, - parseSchtasksListStatus, + isWindowsTaskStateRunning, + resolveWindowsServiceLauncherPath, + resolveWindowsTaskName, resolveWindowsTaskUser, - TASK_NAME, uninstallWindowsService, + WINDOWS_POWERSHELL_COMMAND, } from "./installWindows"; const originalArgv = [...process.argv]; @@ -326,14 +335,10 @@ describe("service manager status parsers", () => { expect(parseLaunchdPrintPid("state = waiting\n")).toBeNull(); }); - it("detects running Windows scheduled tasks from schtasks output", () => { - expect(isSchtasksOutputRunning("TaskName: ADE Runtime\r\nStatus: Running\r\n")).toBe(true); - expect(isSchtasksOutputRunning("TaskName: ADE Runtime\r\nStatus: Ready\r\n")).toBe(false); - }); - - it("parses Windows scheduled task status from schtasks LIST output", () => { - expect(parseSchtasksListStatus("TaskName: ADE Runtime\r\nStatus: Ready\r\n")).toBe("Ready"); - expect(parseSchtasksListStatus("TaskName: ADE Runtime\r\n")).toBeNull(); + it("detects invariant Task Scheduler state values without parsing localized field labels", () => { + expect(isWindowsTaskStateRunning("Running\r\n")).toBe(true); + expect(isWindowsTaskStateRunning("Ready\r\n")).toBe(false); + expect(isWindowsTaskStateRunning("Status: Running\r\n")).toBe(false); }); }); @@ -363,8 +368,12 @@ describe("launchd service rendering", () => { expect(plist).toContain("/opt/ADE & deps"); expect(plist).toContain("ADE_HOME"); expect(plist).toContain("/Users/example/'ade'"); - expect(plist).toContain("/Users/example/'ade'/runtime/launchd.out.log"); - expect(plist).toContain("/Users/example/'ade'/runtime/launchd.err.log"); + expect(plist).toContain( + `${path.join("/Users/example/'ade'", "runtime", "launchd.out.log").replace(/'/g, "'")}`, + ); + expect(plist).toContain( + `${path.join("/Users/example/'ade'", "runtime", "launchd.err.log").replace(/'/g, "'")}`, + ); }); }); @@ -598,7 +607,11 @@ describe("launchd service install", () => { path: servicePath, }); expect(result.message).toContain("Another ADE brain is already hosting mobile sync on port 8801."); - expect(result.message).toContain("brain stop --text"); + expect(result.message).toContain( + process.platform === "win32" + ? `Stop-Process -Id ${existingPid}` + : "brain stop --text", + ); expect(killed).toEqual([]); expect(calls).toEqual([ { command: "launchctl", args: ["print", currentLaunchdDomain()] }, @@ -980,22 +993,42 @@ describe("systemd service install", () => { }); }); -describe("Windows scheduled task helpers", () => { +describe("Windows background service helpers", () => { const serviceCommand: AdeServiceCommand = { command: "C:\\Program Files\\ADE\\ade.exe", - args: ["serve"], + args: ["C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs", "serve"], + env: { + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: "C:\\Program Files\\ADE\\resources\\ade-cli\\node_modules", + ADE_HOME: "C:\\Users\\arul\\.ade-beta", + ADE_PACKAGE_CHANNEL: "beta", + }, }; const taskUser = "ADEBOX\\arul"; + const serviceName = "com.ade.runtime.beta"; + const taskName = resolveWindowsTaskName({ serviceName, userName: taskUser }); it("builds schtasks create, run, query, and delete arguments without invoking schtasks", () => { - const renderedCommand = renderWindowsCommand(serviceCommand); + const renderedCommand = renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + "C:\\Users\\arul\\.ade-beta\\runtime\\brain-service.ps1", + ], + }); - expect(buildWindowsCreateTaskArgs(renderedCommand, taskUser)).toEqual([ + expect(buildWindowsCreateTaskArgs(renderedCommand, taskUser, taskName)).toEqual([ "/Create", "/SC", "ONLOGON", "/TN", - TASK_NAME, + taskName, "/TR", renderedCommand, "/RU", @@ -1003,15 +1036,48 @@ describe("Windows scheduled task helpers", () => { "/IT", "/F", ]); - expect(buildWindowsRunTaskArgs()).toEqual(["/Run", "/TN", TASK_NAME]); - expect(buildWindowsQueryTaskArgs()).toEqual(["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]); - expect(buildWindowsDeleteTaskArgs()).toEqual(["/Delete", "/TN", TASK_NAME, "/F"]); + expect(buildWindowsRunTaskArgs(taskName)).toEqual(["/Run", "/TN", taskName]); + expect(buildWindowsEndTaskArgs(taskName)).toEqual(["/End", "/TN", taskName]); + expect(buildWindowsQueryTaskArgs(taskName)).toEqual([ + "-NoProfile", + "-NonInteractive", + "-Command", + expect.stringContaining(`$_.TaskName -eq '${taskName}'`), + ]); + expect(buildWindowsDeleteTaskArgs(taskName)).toEqual(["/Delete", "/TN", taskName, "/F"]); }); it("resolves the Windows scheduled task user from domain and username environment values", () => { expect(resolveWindowsTaskUser({ USERDOMAIN: "ADEBOX", USERNAME: "arul" })).toBe("ADEBOX\\arul"); expect(resolveWindowsTaskUser({ USERNAME: "LOCALUSER" })).toBe("LOCALUSER"); expect(resolveWindowsTaskUser({ USERDOMAIN: "ADEBOX", USERNAME: "ADEBOX\\arul" })).toBe("ADEBOX\\arul"); + expect(resolveWindowsTaskUser({ + USERDOMAIN: "MicrosoftAccount", + USERNAME: "owner@example.com", + })).toBe("MicrosoftAccount\\owner@example.com"); + }); + + it("isolates scheduled task names by release channel and Windows principal", () => { + const stableArul = resolveWindowsTaskName({ + serviceName: "com.ade.runtime", + userName: "ADEBOX\\arul", + }); + const betaArul = resolveWindowsTaskName({ + serviceName: "com.ade.runtime.beta", + userName: "ADEBOX\\arul", + }); + const betaOtherUser = resolveWindowsTaskName({ + serviceName: "com.ade.runtime.beta", + userName: "ADEBOX\\other", + }); + + expect(stableArul).toMatch(/^ADE Runtime \(stable-[a-f0-9]{12}\)$/); + expect(betaArul).toMatch(/^ADE Runtime \(beta-[a-f0-9]{12}\)$/); + expect(new Set([stableArul, betaArul, betaOtherUser])).toHaveLength(3); + expect(resolveWindowsTaskName({ + serviceName: "com.ade.runtime.beta", + userName: "adebox\\ARUL", + })).toBe(betaArul); }); it("renders Windows scheduled task commands with double-quoted argv tokens", () => { @@ -1019,102 +1085,499 @@ describe("Windows scheduled task helpers", () => { command: "C:\\Program Files\\ADE\\ade.exe", args: ["serve", "--root", "C:\\path with space\\"], })).toBe("\"C:\\Program Files\\ADE\\ade.exe\" \"serve\" \"--root\" \"C:\\path with space\\\\\""); - expect(renderCommand(serviceCommand)).toBe("'C:\\Program Files\\ADE\\ade.exe' 'serve'"); + expect(renderCommand(serviceCommand)).toBe( + "'C:\\Program Files\\ADE\\ade.exe' 'C:\\Program Files\\ADE\\resources\\ade-cli\\cli.cjs' 'serve'", + ); }); - it("rejects embedded double quotes in Windows scheduled task command tokens", () => { - expect(() => renderWindowsCommand({ + it("escapes embedded double quotes in Windows scheduled task command tokens", () => { + expect(renderWindowsCommand({ command: "C:\\Program Files\\ADE\\ade.exe", args: ["serve", "--name", "quoted \"value\""], - })).toThrow("Windows service command arguments cannot contain double quotes."); + })).toBe( + "\"C:\\Program Files\\ADE\\ade.exe\" \"serve\" \"--name\" \"quoted \\\"value\\\"\"", + ); }); - it("starts the scheduled task immediately after a successful create", () => { + it("renders a PowerShell launcher that preserves the service environment and quotes data literally", () => { + const script = renderWindowsServiceLauncher({ + command: "C:\\Program Files\\ADE\\ADE.exe", + args: ["C:\\Program Files\\ADE\\cli.cjs", "serve", "quoted \"value\"", "O'Brien"], + env: { + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: "C:\\ADE deps\\100% & O'Brien", + ADE_HOME: "C:\\Users\\arul\\.ade-beta", + }, + }); + + expect(script).toContain( + "[System.Environment]::SetEnvironmentVariable('ELECTRON_RUN_AS_NODE', '1', 'Process')", + ); + expect(script).toContain( + "[System.Environment]::SetEnvironmentVariable('NODE_PATH', 'C:\\ADE deps\\100% & O''Brien', 'Process')", + ); + expect(script).toContain("$startInfo.FileName = 'C:\\Program Files\\ADE\\ADE.exe'"); + expect(script).toContain( + "$startInfo.Arguments = '\"C:\\Program Files\\ADE\\cli.cjs\" \"serve\" \"quoted \\\"value\\\"\" \"O''Brien\"'", + ); + expect(script).toContain("$startInfo.CreateNoWindow = $true"); + expect(script).toContain( + "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden", + ); + expect(script).toContain("$process = [System.Diagnostics.Process]::Start($startInfo)"); + }); + + (process.platform === "win32" ? it : it.skip)( + "executes the generated PowerShell launcher with literal environment and argv values", + () => { + const launcherPath = path.join( + makeTempHome("ade-windows-service-exec-"), + "brain-service.ps1", + ); + const outputPath = path.join(path.dirname(launcherPath), "result.json"); + fs.writeFileSync( + launcherPath, + `\uFEFF${renderWindowsServiceLauncher({ + command: process.execPath, + args: [ + "-e", + "require('node:fs').writeFileSync(process.env.ADE_TEST_OUTPUT, JSON.stringify({ value: process.env.ADE_TEST_VALUE, args: process.argv.slice(1) }), 'utf8')", + "quoted \"value\"", + "O'Brien", + "100% & $HOME", + "naïve-東京-🚀", + ], + env: { + ADE_TEST_VALUE: "C:\\ADE deps\\naïve-東京-🚀\\100% & O'Brien", + ADE_TEST_OUTPUT: outputPath, + }, + })}`, + "utf8", + ); + + const result = spawnChildSync( + WINDOWS_POWERSHELL_COMMAND, + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", launcherPath], + { encoding: "utf8", windowsHide: true }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(fs.readFileSync(outputPath, "utf8"))).toEqual({ + value: "C:\\ADE deps\\naïve-東京-🚀\\100% & O'Brien", + args: ["quoted \"value\"", "O'Brien", "100% & $HOME", "naïve-東京-🚀"], + }); + }, + ); + + it("registers and starts the per-user background service without Task Scheduler", () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ - { status: 0, stdout: "SUCCESS: created", stderr: "" }, - { status: 0, stdout: "SUCCESS: attempted to run", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: value not found" }, + { status: 0, stdout: "The operation completed successfully.", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-"), "brain-service.ps1"); + const pidPath = `${launcherPath}.pid.json`; - const result = installWindowsService({ command: serviceCommand, spawnSync, userName: taskUser }); + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); expect(result).toMatchObject({ ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "install", - path: TASK_NAME, - message: "ADE service scheduled task installed and started.", + path: taskName, + message: "ADE per-user startup entry installed and background service started.", + }); + expect(fs.readFileSync(launcherPath, "utf8")).toBe( + `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { pidPath })}`, + ); + const scheduledCommand = renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ], + }); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyAddArgs(taskName, scheduledCommand) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsStartLauncherArgs(launcherPath) }, + ]); + }); + + it("ends and replaces a running channel task before starting the repaired runtime", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "SUCCESS: ended", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-repair-"), "brain-service.ps1"); + + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(true); + expect(calls.slice(0, 4)).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + ]); + expect(calls.at(-2)?.args).toEqual(expect.arrayContaining(["ADD", "/V", taskName])); + expect(calls.at(-1)?.args).toEqual(buildWindowsStartLauncherArgs(launcherPath)); + }); + + it("ends and deletes only the exact legacy task before installing the channel task", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "SUCCESS: ended", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 0, stdout: "SUCCESS: created", stderr: "" }, + { status: 0, stdout: "1234", stderr: "" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-migrate-"), "brain-service.ps1"); + + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(true); + expect(calls.slice(0, 3)).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs("ADE Runtime") }, + ]); + expect(calls.flatMap((call) => call.args)).not.toContain("ADE Runtime "); + }); + + it("does not register or start a channel task when the running legacy task cannot be ended", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Running", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: access is denied" }, + ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-migrate-fail-"), "brain-service.ps1"); + + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, }); + + expect(result.ok).toBe(false); + expect(result.message).toContain("legacy ADE Runtime scheduled task"); expect(calls).toEqual([ - { command: "schtasks.exe", args: buildWindowsCreateTaskArgs(renderWindowsCommand(serviceCommand), taskUser) }, - { command: "schtasks.exe", args: buildWindowsRunTaskArgs() }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, ]); }); - it("surfaces a clear install failure when create succeeds but immediate start fails", () => { + it("removes the per-user startup entry when immediate start fails", () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, { status: 0, stdout: "SUCCESS: created", stderr: "" }, { status: 1, stdout: "", stderr: "ERROR: access is denied" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-start-fail-"), "brain-service.ps1"); - const result = installWindowsService({ command: serviceCommand, spawnSync, userName: taskUser }); + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); expect(result.ok).toBe(false); - expect(result.message).toBe("ADE service scheduled task installed, but failed to start: ERROR: access is denied"); + expect(result.message).toBe("ADE per-user startup entry was installed, but the background service failed to start: ERROR: access is denied"); + const scheduledCommand = renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ], + }); expect(calls.map((call) => call.args)).toEqual([ - buildWindowsCreateTaskArgs(renderWindowsCommand(serviceCommand), taskUser), - buildWindowsRunTaskArgs(), + buildWindowsQueryTaskArgs("ADE Runtime"), + buildWindowsQueryTaskArgs(taskName), + buildWindowsRunKeyQueryArgs(taskName), + buildWindowsRunKeyAddArgs(taskName, scheduledCommand), + buildWindowsStartLauncherArgs(launcherPath), + buildWindowsRunKeyDeleteArgs(taskName), ]); }); - it("does not try to run the task when create fails", () => { + it("does not start the service when per-user registration fails", () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ - { status: 1, stdout: "", stderr: "ERROR: create failed" }, + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "ERROR: registration failed" }, ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-create-fail-"), "brain-service.ps1"); - const result = installWindowsService({ command: serviceCommand, spawnSync, userName: taskUser }); + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); expect(result.ok).toBe(false); - expect(result.message).toBe("ERROR: create failed"); - expect(calls).toHaveLength(1); + expect(result.message).toBe("ERROR: registration failed"); + expect(calls).toEqual([ + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, + expect.objectContaining({ command: "reg.exe", args: expect.arrayContaining(["ADD"]) }), + ]); }); - it("reports successful scheduled task removal", () => { + it("removes legacy tasks and the per-user startup entry", () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Ready", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "SUCCESS: ended", stderr: "" }, + { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, + { status: 0, stdout: "startup value", stderr: "" }, { status: 0, stdout: "SUCCESS: deleted", stderr: "" }, ]); + const launcherPath = path.join(makeTempHome("ade-windows-service-remove-"), "brain-service.ps1"); + fs.writeFileSync(launcherPath, "old launcher", "utf8"); - const result = uninstallWindowsService({ spawnSync }); + const result = uninstallWindowsService({ + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); expect(result).toMatchObject({ ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "uninstall", - path: TASK_NAME, - message: "ADE service scheduled task removed.", + path: taskName, + message: "ADE background service startup entry removed.", }); expect(calls).toEqual([ - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs() }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsEndTaskArgs("ADE Runtime") }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs("ADE Runtime") }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, + { command: "reg.exe", args: buildWindowsRunKeyDeleteArgs(taskName) }, ]); + expect(fs.existsSync(launcherPath)).toBe(false); }); it("surfaces scheduled task removal failures", () => { const calls: Array<{ command: string; args: string[] }> = []; const spawnSync = spawnSequence(calls, [ + { status: 0, stdout: "Ready", stderr: "" }, { status: 1, stdout: "", stderr: "ERROR: The system cannot find the file specified." }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, ]); - const result = uninstallWindowsService({ spawnSync }); + const result = uninstallWindowsService({ serviceName, spawnSync, userName: taskUser }); expect(result.ok).toBe(false); - expect(result.message).toBe("ERROR: The system cannot find the file specified."); + expect(result.message).toContain("ERROR: The system cannot find the file specified."); expect(calls).toEqual([ - { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs() }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs(taskName) }, + { command: "schtasks.exe", args: buildWindowsDeleteTaskArgs(taskName) }, + { command: WINDOWS_POWERSHELL_COMMAND, args: buildWindowsQueryTaskArgs("ADE Runtime") }, + { command: "reg.exe", args: buildWindowsRunKeyQueryArgs(taskName) }, ]); }); + + it("fails uninstall when the scheduled task launcher cannot be removed", () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawnSync = spawnSequence(calls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + ]); + const launcherPath = makeTempHome("ade-windows-service-launcher-dir-"); + + const result = uninstallWindowsService({ + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result).toMatchObject({ + ok: false, + serviceName, + action: "uninstall", + path: launcherPath, + }); + expect(result.message).toContain("launcher could not be deleted"); + }); + + it("queries Task Scheduler state through PowerShell instead of localized schtasks labels", () => { + const calls: Array<{ + command: string; + args: string[]; + options: import("node:child_process").SpawnSyncOptions | undefined; + }> = []; + const spawnSync: ServiceManagerSpawnSync = (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0, stdout: "Running", stderr: "" }; + }; + + expect(getWindowsServiceStatus({ serviceName, spawnSync, userName: taskUser })).toMatchObject({ + ok: true, + installed: true, + running: true, + path: taskName, + }); + expect(calls).toEqual([ + { + command: WINDOWS_POWERSHELL_COMMAND, + args: buildWindowsQueryTaskArgs(taskName), + options: expect.objectContaining({ windowsHide: true }), + }, + ]); + }); + + it("hides every Windows scheduled-task lifecycle subprocess", () => { + const calls: Array<{ + command: string; + args: string[]; + options: import("node:child_process").SpawnSyncOptions | undefined; + }> = []; + const results: ServiceManagerProcessResult[] = [ + { status: 0, stdout: "Running", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 3, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + { status: 0, stdout: "", stderr: "" }, + ]; + const spawnSync: ServiceManagerSpawnSync = (command, args, options) => { + calls.push({ command, args, options }); + return results.shift() ?? { status: 0, stdout: "", stderr: "" }; + }; + const launcherPath = path.join( + makeTempHome("ade-windows-service-hidden-"), + "brain-service.ps1", + ); + + const result = installWindowsService({ + command: serviceCommand, + launcherPath, + serviceName, + spawnSync, + userName: taskUser, + }); + + expect(result.ok).toBe(true); + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.options?.windowsHide === true)).toBe(true); + }); + + it("distinguishes an absent task from a failed locale-independent status query", () => { + const absentCalls: Array<{ command: string; args: string[] }> = []; + const absent = getWindowsServiceStatus({ + serviceName, + spawnSync: spawnSequence(absentCalls, [ + { status: 3, stdout: "", stderr: "" }, + { status: 1, stdout: "", stderr: "" }, + ]), + userName: taskUser, + }); + const failedCalls: Array<{ command: string; args: string[] }> = []; + const failed = getWindowsServiceStatus({ + serviceName, + spawnSync: spawnSequence(failedCalls, [ + { status: 1, stdout: "", stderr: "PowerShell unavailable" }, + { status: 1, stdout: "", stderr: "" }, + ]), + userName: taskUser, + }); + + expect(absent).toMatchObject({ ok: true, installed: false, running: false }); + expect(failed).toMatchObject({ ok: false, installed: null, running: null }); + }); + + ( + process.platform === "win32" + && !os.userInfo().username.toLowerCase().startsWith("codexsandbox") + ? it + : it.skip + )( + "returns the dedicated not-found exit code from a real locale-independent task query", + () => { + const missingTaskName = `ADE Runtime Test ${process.pid} ${Date.now()}`; + const result = spawnChildSync( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsQueryTaskArgs(missingTaskName), + { encoding: "utf8" }, + ); + + expect(result.status, result.stderr).toBe(3); + expect(result.stdout).toBe(""); + }, + ); + + it("derives the launcher path from the channel-local ADE home", () => { + expect(resolveWindowsServiceLauncherPath({ + env: { ADE_HOME: "C:\\Users\\arul\\.ade-beta" }, + serviceName, + })).toMatch(/^C:\\Users\\arul\\\.ade-beta\\runtime\\brain-service-[a-f0-9]{12}\.ps1$/i); + }); }); function spawnSequence( diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts index e0b0a5ed1..76caf5f1c 100644 --- a/apps/ade-cli/src/serviceManager/common.ts +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -103,6 +103,7 @@ const RUNTIME_ENV_PASSTHROUGH = [ "ADE_RUNTIME_ROOT", "ADE_RUNTIME_NODE_MODULES", "ADE_DEFAULT_ROLE", + "ADE_WINDOWS_USER_SID", ] as const; function runtimeEnvironment(): Record | undefined { @@ -342,9 +343,6 @@ export function shellQuote(value: string): string { } export function cmdQuote(value: string): string { - if (value.includes("\"")) { - throw new Error("Windows service command arguments cannot contain double quotes."); - } let quoted = "\""; let backslashes = 0; for (const char of value) { @@ -352,6 +350,12 @@ export function cmdQuote(value: string): string { backslashes += 1; continue; } + if (char === "\"") { + quoted += "\\".repeat((backslashes * 2) + 1); + quoted += "\""; + backslashes = 0; + continue; + } quoted += "\\".repeat(backslashes); quoted += char; backslashes = 0; @@ -395,6 +399,62 @@ export function renderWindowsCommand(command: AdeServiceCommand): string { return [command.command, ...command.args].map(cmdQuote).join(" "); } +function powerShellSingleQuotedLiteral(value: string): string { + if (value.includes("\0")) { + throw new Error("Windows service command values cannot contain NUL bytes."); + } + return `'${value.replace(/'/g, "''")}'`; +} + +export function renderWindowsServiceLauncher( + command: AdeServiceCommand, + options: { pidPath?: string } = {}, +): string { + const environment = Object.entries(command.env ?? {}).sort(([left], [right]) => + left.localeCompare(right), + ); + const environmentLines = environment.map(([key, value]) => { + if (!key || key.includes("=") || key.includes("\0")) { + throw new Error(`Invalid Windows service environment variable name: ${JSON.stringify(key)}.`); + } + return `[System.Environment]::SetEnvironmentVariable(${powerShellSingleQuotedLiteral(key)}, ${powerShellSingleQuotedLiteral(value)}, 'Process')`; + }); + const commandLine = command.args.map(cmdQuote).join(" "); + const processLines = options.pidPath + ? [ + "$process = [System.Diagnostics.Process]::Start($startInfo)", + "if ($null -eq $process) { throw 'Windows failed to start the ADE brain process.' }", + "$pidRecord = '{\"supervisorPid\":' + $PID.ToString([Globalization.CultureInfo]::InvariantCulture) + ',\"runtimePid\":' + $process.Id.ToString([Globalization.CultureInfo]::InvariantCulture) + '}'", + `[IO.File]::WriteAllText(${powerShellSingleQuotedLiteral(options.pidPath)}, $pidRecord, [Text.Encoding]::ASCII)`, + "try {", + " $process.WaitForExit()", + " $exitCode = $process.ExitCode", + "} finally {", + ` Remove-Item -LiteralPath ${powerShellSingleQuotedLiteral(options.pidPath)} -Force -ErrorAction SilentlyContinue`, + "}", + "exit $exitCode", + ] + : [ + "$process = [System.Diagnostics.Process]::Start($startInfo)", + "if ($null -eq $process) { throw 'Windows failed to start the ADE brain process.' }", + "$process.WaitForExit()", + "exit $process.ExitCode", + ]; + + return [ + "$ErrorActionPreference = 'Stop'", + ...environmentLines, + "$startInfo = New-Object System.Diagnostics.ProcessStartInfo", + `$startInfo.FileName = ${powerShellSingleQuotedLiteral(command.command)}`, + `$startInfo.Arguments = ${powerShellSingleQuotedLiteral(commandLine)}`, + "$startInfo.UseShellExecute = $false", + "$startInfo.CreateNoWindow = $true", + "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden", + ...processLines, + "", + ].join("\r\n"); +} + function streamToText(value: string | Buffer | null | undefined): string { if (typeof value === "string") return value.trim(); if (Buffer.isBuffer(value)) return value.toString("utf8").trim(); diff --git a/apps/ade-cli/src/serviceManager/index.ts b/apps/ade-cli/src/serviceManager/index.ts index 5642b3de5..91d40f304 100644 --- a/apps/ade-cli/src/serviceManager/index.ts +++ b/apps/ade-cli/src/serviceManager/index.ts @@ -3,7 +3,12 @@ import type { ServiceManagerResult, ServiceManagerStatusResult } from "./common" import { ADE_RUNTIME_SERVICE_NAME } from "./common"; import { getLaunchdServiceMainPid, getLaunchdServiceStatus, installLaunchdService, uninstallLaunchdService } from "./installLaunchd"; import { getSystemdServiceStatus, installSystemdService, uninstallSystemdService } from "./installSystemd"; -import { getWindowsServiceStatus, installWindowsService, uninstallWindowsService } from "./installWindows"; +import { + getWindowsServiceStatus, + installWindowsService, + readWindowsServicePidRecord, + uninstallWindowsService, +} from "./installWindows"; export type { ServiceManagerResult, ServiceManagerStatusResult } from "./common"; @@ -25,6 +30,8 @@ export function getRuntimeServiceMainPid(): number | null { const pid = Number(String(result.stdout ?? "").trim()); return Number.isFinite(pid) && pid > 0 ? Math.floor(pid) : null; } + case "win32": + return readWindowsServicePidRecord()?.runtimePid ?? null; default: return null; } diff --git a/apps/ade-cli/src/serviceManager/installWindows.ts b/apps/ade-cli/src/serviceManager/installWindows.ts index 9606b1d8a..bb588abc9 100644 --- a/apps/ade-cli/src/serviceManager/installWindows.ts +++ b/apps/ade-cli/src/serviceManager/installWindows.ts @@ -1,20 +1,38 @@ import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; import { ADE_RUNTIME_SERVICE_NAME, type AdeServiceCommand, renderWindowsCommand, + renderWindowsServiceLauncher, resolveAdeServeCommand, serviceManagerResultText, type ServiceManagerResult, type ServiceManagerSpawnSync, type ServiceManagerStatusResult, } from "./common"; +import { resolveMachineAdeDir } from "../services/projects/machineLayout"; export const TASK_NAME = "ADE Runtime"; +export const WINDOWS_POWERSHELL_COMMAND = "powershell.exe"; +export const WINDOWS_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +const TASK_NOT_FOUND_EXIT_CODE = 3; +const REGISTRY_VALUE_NOT_FOUND_EXIT_CODE = 1; + +export type WindowsServicePidRecord = { + supervisorPid: number; + runtimePid: number; +}; type WindowsServiceManagerDeps = { command?: AdeServiceCommand; + env?: NodeJS.ProcessEnv; + launcherPath?: string; + pidPath?: string; + serviceName?: string; spawnSync?: ServiceManagerSpawnSync; userName?: string; }; @@ -22,7 +40,7 @@ type WindowsServiceManagerDeps = { export function resolveWindowsTaskUser(env: NodeJS.ProcessEnv = process.env): string { const username = env.USERNAME?.trim() || os.userInfo().username.trim(); if (!username) { - throw new Error("Unable to resolve current Windows user for scheduled task registration."); + throw new Error("Unable to resolve the current Windows user for background-service registration."); } const domain = env.USERDOMAIN?.trim(); if (domain && !username.includes("\\")) { @@ -31,13 +49,78 @@ export function resolveWindowsTaskUser(env: NodeJS.ProcessEnv = process.env): st return username; } -export function buildWindowsCreateTaskArgs(command: string, userName = resolveWindowsTaskUser()): string[] { +function serviceChannelLabel(serviceName: string): string { + const normalized = serviceName.trim().toLowerCase(); + if (normalized === "com.ade.runtime") return "stable"; + if (normalized.endsWith(".alpha")) return "alpha"; + if (normalized.endsWith(".beta")) return "beta"; + return "custom"; +} + +function shortHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} + +export function resolveWindowsTaskName(args: { + serviceName?: string; + userName?: string; +} = {}): string { + const serviceName = args.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + const userName = args.userName ?? resolveWindowsTaskUser(); + const identity = `${serviceName.trim().toLowerCase()}\0${userName.trim().toLowerCase()}`; + return `${TASK_NAME} (${serviceChannelLabel(serviceName)}-${shortHash(identity)})`; +} + +export function resolveWindowsServiceLauncherPath(args: { + env?: NodeJS.ProcessEnv; + serviceName?: string; +} = {}): string { + const env = args.env ?? process.env; + const serviceName = args.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + const adeDir = path.win32.resolve(env.ADE_HOME?.trim() || resolveMachineAdeDir(env)); + return path.win32.join( + adeDir, + "runtime", + `brain-service-${shortHash(serviceName.trim().toLowerCase())}.ps1`, + ); +} + +export function resolveWindowsServicePidPath(args: { + env?: NodeJS.ProcessEnv; + serviceName?: string; +} = {}): string { + return `${resolveWindowsServiceLauncherPath(args)}.pid.json`; +} + +export function readWindowsServicePidRecord(args: { + env?: NodeJS.ProcessEnv; + serviceName?: string; + pidPath?: string; +} = {}): WindowsServicePidRecord | null { + const pidPath = args.pidPath ?? resolveWindowsServicePidPath(args); + try { + const parsed = JSON.parse(fs.readFileSync(pidPath, "utf8")) as Partial; + const supervisorPid = Number(parsed.supervisorPid); + const runtimePid = Number(parsed.runtimePid); + if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) return null; + if (!Number.isInteger(runtimePid) || runtimePid <= 0) return null; + return { supervisorPid, runtimePid }; + } catch { + return null; + } +} + +export function buildWindowsCreateTaskArgs( + command: string, + userName = resolveWindowsTaskUser(), + taskName = resolveWindowsTaskName({ userName }), +): string[] { return [ "/Create", "/SC", "ONLOGON", "/TN", - TASK_NAME, + taskName, "/TR", command, "/RU", @@ -47,115 +130,519 @@ export function buildWindowsCreateTaskArgs(command: string, userName = resolveWi ]; } -export function buildWindowsRunTaskArgs(): string[] { - return ["/Run", "/TN", TASK_NAME]; +export function buildWindowsRunTaskArgs( + taskName = resolveWindowsTaskName(), +): string[] { + return ["/Run", "/TN", taskName]; +} + +export function buildWindowsEndTaskArgs( + taskName = resolveWindowsTaskName(), +): string[] { + return ["/End", "/TN", taskName]; +} + +function powerShellSingleQuotedLiteral(value: string): string { + if (value.includes("\0")) { + throw new Error("Windows scheduled task names cannot contain NUL bytes."); + } + return `'${value.replace(/'/g, "''")}'`; +} + +export function buildWindowsQueryTaskArgs( + taskName = resolveWindowsTaskName(), +): string[] { + const taskNameLiteral = powerShellSingleQuotedLiteral(taskName); + const query = [ + "$ErrorActionPreference = 'Stop'", + `try { $task = Get-ScheduledTask -TaskPath '\\' -ErrorAction Stop | Where-Object { $_.TaskName -eq ${taskNameLiteral} } | Select-Object -First 1 } catch { [Console]::Error.Write($_.Exception.Message); exit 4 }`, + `if ($null -eq $task) { exit ${TASK_NOT_FOUND_EXIT_CODE} }`, + "[Console]::Out.Write($task.State.ToString())", + ].join("; "); + return ["-NoProfile", "-NonInteractive", "-Command", query]; +} + +export function buildWindowsDeleteTaskArgs( + taskName = resolveWindowsTaskName(), +): string[] { + return ["/Delete", "/TN", taskName, "/F"]; +} + +export function buildWindowsRunKeyQueryArgs(valueName: string): string[] { + return ["QUERY", WINDOWS_RUN_KEY, "/V", valueName]; +} + +export function buildWindowsRunKeyAddArgs(valueName: string, command: string): string[] { + return ["ADD", WINDOWS_RUN_KEY, "/V", valueName, "/T", "REG_SZ", "/D", command, "/F"]; } -export function buildWindowsQueryTaskArgs(): string[] { - return ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]; +export function buildWindowsRunKeyDeleteArgs(valueName: string): string[] { + return ["DELETE", WINDOWS_RUN_KEY, "/V", valueName, "/F"]; } -export function buildWindowsDeleteTaskArgs(): string[] { - return ["/Delete", "/TN", TASK_NAME, "/F"]; +export function buildWindowsStartLauncherArgs(launcherPath: string): string[] { + const childArgs = [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ]; + const startCommand = [ + `$process = Start-Process -FilePath ${powerShellSingleQuotedLiteral(WINDOWS_POWERSHELL_COMMAND)}`, + `-ArgumentList @(${childArgs.map(powerShellSingleQuotedLiteral).join(", ")})`, + "-WindowStyle Hidden -PassThru", + ].join(" "); + const command = `${startCommand}; [Console]::Out.Write($process.Id)`; + return ["-NoProfile", "-NonInteractive", "-Command", command]; } -export function parseSchtasksListStatus(output: string): string | null { - const match = /^\s*Status:\s*(.*?)\s*$/im.exec(output); - return match?.[1] ?? null; +export function buildWindowsSupervisorQueryArgs(pid: number, launcherPath: string): string[] { + const launcherLiteral = powerShellSingleQuotedLiteral(launcherPath); + const query = [ + "$ErrorActionPreference = 'Stop'", + `$process = Get-CimInstance Win32_Process -Filter ${powerShellSingleQuotedLiteral(`ProcessId = ${pid}`)} -ErrorAction SilentlyContinue`, + "if ($null -eq $process) { exit 3 }", + "$commandLine = [string]$process.CommandLine", + `$matchesLauncher = $commandLine.IndexOf(${launcherLiteral}, [StringComparison]::OrdinalIgnoreCase) -ge 0`, + "if (-not $matchesLauncher -or $process.Name -notmatch '^powershell(?:\\.exe)?$') { exit 4 }", + "[Console]::Out.Write($process.ProcessId)", + ].join("; "); + return ["-NoProfile", "-NonInteractive", "-Command", query]; } -export function isSchtasksOutputRunning(output: string): boolean { - return parseSchtasksListStatus(output)?.toLowerCase() === "running"; +export function isWindowsTaskStateRunning(output: string | Buffer | null | undefined): boolean { + const state = Buffer.isBuffer(output) ? output.toString("utf8") : output ?? ""; + return state.trim().toLowerCase() === "running"; +} + +type WindowsTaskRemovalResult = + | { ok: true; removed: boolean } + | { ok: false; message: string }; + +function removeWindowsTaskIfPresent( + run: ServiceManagerSpawnSync, + taskName: string, + description: string, +): WindowsTaskRemovalResult { + const query = run( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsQueryTaskArgs(taskName), + { encoding: "utf8", windowsHide: true }, + ); + if (query.status === TASK_NOT_FOUND_EXIT_CODE) { + return { ok: true, removed: false }; + } + if (query.status !== 0) { + return { + ok: false, + message: `Unable to query the ${description}: ${serviceManagerResultText(query) || "PowerShell task query failed."}`, + }; + } + if (isWindowsTaskStateRunning(query.stdout)) { + const end = run("schtasks.exe", buildWindowsEndTaskArgs(taskName), { + encoding: "utf8", + windowsHide: true, + }); + if (end.status !== 0) { + return { + ok: false, + message: `Unable to end the ${description}: ${serviceManagerResultText(end) || "schtasks end failed."}`, + }; + } + } + const remove = run("schtasks.exe", buildWindowsDeleteTaskArgs(taskName), { + encoding: "utf8", + windowsHide: true, + }); + if (remove.status !== 0) { + return { + ok: false, + message: `Unable to delete the ${description}: ${serviceManagerResultText(remove) || "schtasks delete failed."}`, + }; + } + return { ok: true, removed: true }; +} + +function windowsLauncherCommand(launcherPath: string): string { + return renderWindowsCommand({ + command: WINDOWS_POWERSHELL_COMMAND, + args: [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + launcherPath, + ], + }); +} + +function queryWindowsSupervisor( + run: ServiceManagerSpawnSync, + launcherPath: string, + pidPath: string, +): { running: boolean; pid: number | null; error: string | null } { + const record = readWindowsServicePidRecord({ pidPath }); + if (!record) return { running: false, pid: null, error: null }; + const result = run( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsSupervisorQueryArgs(record.supervisorPid, launcherPath), + { encoding: "utf8", windowsHide: true }, + ); + if (result.status === 0) { + return { running: true, pid: record.supervisorPid, error: null }; + } + if (result.status === 3 || result.status === 4) { + try { fs.rmSync(pidPath, { force: true }); } catch { /* advisory record */ } + return { running: false, pid: null, error: null }; + } + return { + running: false, + pid: null, + error: serviceManagerResultText(result) || "Unable to inspect the ADE startup process.", + }; +} + +function removeWindowsRunEntryIfPresent( + run: ServiceManagerSpawnSync, + valueName: string, + launcherPath: string, + pidPath: string, +): WindowsTaskRemovalResult { + const query = run("reg.exe", buildWindowsRunKeyQueryArgs(valueName), { + encoding: "utf8", + windowsHide: true, + }); + const installed = query.status === 0; + if (!installed && query.status !== REGISTRY_VALUE_NOT_FOUND_EXIT_CODE) { + return { + ok: false, + message: `Unable to query the ADE per-user startup entry: ${serviceManagerResultText(query) || "reg query failed."}`, + }; + } + + const supervisor = queryWindowsSupervisor(run, launcherPath, pidPath); + if (supervisor.error) return { ok: false, message: supervisor.error }; + if (supervisor.running && supervisor.pid) { + const stop = run("taskkill.exe", ["/PID", String(supervisor.pid), "/T", "/F"], { + encoding: "utf8", + windowsHide: true, + }); + if (stop.status !== 0) { + const recheck = queryWindowsSupervisor(run, launcherPath, pidPath); + if (recheck.running || recheck.error) { + return { + ok: false, + message: `Unable to stop the ADE startup process: ${serviceManagerResultText(stop) || recheck.error || "taskkill failed."}`, + }; + } + } + } + + if (installed) { + const remove = run("reg.exe", buildWindowsRunKeyDeleteArgs(valueName), { + encoding: "utf8", + windowsHide: true, + }); + if (remove.status !== 0) { + return { + ok: false, + message: `Unable to delete the ADE per-user startup entry: ${serviceManagerResultText(remove) || "reg delete failed."}`, + }; + } + } + try { fs.rmSync(pidPath, { force: true }); } catch { /* advisory record */ } + return { ok: true, removed: installed || supervisor.running }; } export function installWindowsService(deps: WindowsServiceManagerDeps = {}): ServiceManagerResult { const run = deps.spawnSync ?? spawnSync; - const command = renderWindowsCommand(deps.command ?? resolveAdeServeCommand()); + const env = deps.env ?? process.env; + const serviceName = deps.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + const serviceCommand = deps.command ?? resolveAdeServeCommand(); let userName: string; try { - userName = deps.userName ?? resolveWindowsTaskUser(); + userName = deps.userName ?? resolveWindowsTaskUser(env); } catch (error) { return { ok: false, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "install", - path: TASK_NAME, + path: null, message: error instanceof Error ? error.message : "Unable to resolve current Windows user.", }; } - const result = run("schtasks.exe", buildWindowsCreateTaskArgs(command, userName), { encoding: "utf8" }); - if (result.status !== 0) { + const taskName = resolveWindowsTaskName({ serviceName, userName }); + const launcherPath = deps.launcherPath ?? resolveWindowsServiceLauncherPath({ env, serviceName }); + const pidPath = deps.pidPath ?? `${launcherPath}.pid.json`; + try { + fs.mkdirSync(path.dirname(launcherPath), { recursive: true }); + fs.writeFileSync(launcherPath, `\uFEFF${renderWindowsServiceLauncher(serviceCommand, { pidPath })}`, { + encoding: "utf8", + mode: 0o600, + }); + } catch (error) { + return { + ok: false, + serviceName, + action: "install", + path: taskName, + message: error instanceof Error + ? `Unable to write the Windows brain launcher: ${error.message}` + : "Unable to write the Windows brain launcher.", + }; + } + const legacyRemoval = removeWindowsTaskIfPresent( + run, + TASK_NAME, + "legacy ADE Runtime scheduled task", + ); + if (!legacyRemoval.ok) { + return { + ok: false, + serviceName, + action: "install", + path: taskName, + message: legacyRemoval.message, + }; + } + const currentRemoval = removeWindowsTaskIfPresent( + run, + taskName, + "existing ADE service scheduled task", + ); + if (!currentRemoval.ok) { + return { + ok: false, + serviceName, + action: "install", + path: taskName, + message: currentRemoval.message, + }; + } + const startupRemoval = removeWindowsRunEntryIfPresent( + run, + taskName, + launcherPath, + pidPath, + ); + if (!startupRemoval.ok) { + return { + ok: false, + serviceName, + action: "install", + path: taskName, + message: startupRemoval.message, + }; + } + const command = windowsLauncherCommand(launcherPath); + const registration = run( + "reg.exe", + buildWindowsRunKeyAddArgs(taskName, command), + { encoding: "utf8", windowsHide: true }, + ); + if (registration.status !== 0) { return { ok: false, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "install", - path: TASK_NAME, - message: serviceManagerResultText(result) || "schtasks create failed.", + path: taskName, + message: serviceManagerResultText(registration) || "Unable to create the ADE per-user startup entry.", }; } - const start = run("schtasks.exe", buildWindowsRunTaskArgs(), { encoding: "utf8" }); + const start = run(WINDOWS_POWERSHELL_COMMAND, buildWindowsStartLauncherArgs(launcherPath), { + encoding: "utf8", + windowsHide: true, + }); if (start.status !== 0) { + run("reg.exe", buildWindowsRunKeyDeleteArgs(taskName), { + encoding: "utf8", + windowsHide: true, + }); return { ok: false, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "install", - path: TASK_NAME, - message: `ADE service scheduled task installed, but failed to start: ${serviceManagerResultText(start) || "schtasks run failed."}`, + path: taskName, + message: `ADE per-user startup entry was installed, but the background service failed to start: ${serviceManagerResultText(start) || "PowerShell launch failed."}`, }; } return { ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "install", - path: TASK_NAME, - message: "ADE service scheduled task installed and started.", + path: taskName, + message: "ADE per-user startup entry installed and background service started.", }; } -export function uninstallWindowsService(deps: Pick = {}): ServiceManagerResult { +export function uninstallWindowsService(deps: WindowsServiceManagerDeps = {}): ServiceManagerResult { const run = deps.spawnSync ?? spawnSync; - const result = run("schtasks.exe", buildWindowsDeleteTaskArgs(), { encoding: "utf8" }); - if (result.status !== 0) { + const env = deps.env ?? process.env; + const serviceName = deps.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + let userName: string; + try { + userName = deps.userName ?? resolveWindowsTaskUser(env); + } catch (error) { + return { + ok: false, + serviceName, + action: "uninstall", + path: null, + message: error instanceof Error ? error.message : "Unable to resolve current Windows user.", + }; + } + const taskName = resolveWindowsTaskName({ serviceName, userName }); + const launcherPath = deps.launcherPath ?? resolveWindowsServiceLauncherPath({ env, serviceName }); + const pidPath = deps.pidPath ?? `${launcherPath}.pid.json`; + const currentRemoval = removeWindowsTaskIfPresent( + run, + taskName, + "ADE service scheduled task", + ); + const legacyRemoval = taskName === TASK_NAME + ? { ok: true as const, removed: false } + : removeWindowsTaskIfPresent( + run, + TASK_NAME, + "legacy ADE Runtime scheduled task", + ); + const startupRemoval = removeWindowsRunEntryIfPresent( + run, + taskName, + launcherPath, + pidPath, + ); + const removalErrors = [currentRemoval, legacyRemoval, startupRemoval] + .filter((result): result is Extract => !result.ok) + .map((result) => result.message); + if (removalErrors.length > 0) { return { ok: false, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "uninstall", - path: TASK_NAME, - message: serviceManagerResultText(result) || "schtasks delete failed.", + path: taskName, + message: removalErrors.join(" "), + }; + } + try { + fs.rmSync(launcherPath, { force: true }); + } catch (error) { + return { + ok: false, + serviceName, + action: "uninstall", + path: launcherPath, + message: `ADE startup entry was removed, but its launcher could not be deleted: ${ + error instanceof Error ? error.message : String(error) + }`, }; } return { ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "uninstall", - path: TASK_NAME, - message: "ADE service scheduled task removed.", + path: taskName, + message: "ADE background service startup entry removed.", }; } -export function getWindowsServiceStatus(): ServiceManagerStatusResult { - const result = spawnSync("schtasks.exe", buildWindowsQueryTaskArgs(), { encoding: "utf8" }); - if (result.status !== 0) { +export function getWindowsServiceStatus( + deps: Pick = {}, +): ServiceManagerStatusResult { + const run = deps.spawnSync ?? spawnSync; + const env = deps.env ?? process.env; + const serviceName = deps.serviceName ?? ADE_RUNTIME_SERVICE_NAME; + let userName: string; + try { + userName = deps.userName ?? resolveWindowsTaskUser(env); + } catch (error) { + return { + ok: false, + serviceName, + action: "status", + installed: null, + running: null, + path: null, + message: error instanceof Error ? error.message : "Unable to resolve current Windows user.", + }; + } + const taskName = resolveWindowsTaskName({ serviceName, userName }); + const launcherPath = deps.launcherPath ?? resolveWindowsServiceLauncherPath({ env, serviceName }); + const pidPath = deps.pidPath ?? `${launcherPath}.pid.json`; + const taskResult = run( + WINDOWS_POWERSHELL_COMMAND, + buildWindowsQueryTaskArgs(taskName), + { encoding: "utf8", windowsHide: true }, + ); + if (taskResult.status === 0) { + const running = isWindowsTaskStateRunning(taskResult.stdout); return { ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, + action: "status", + installed: true, + running, + path: taskName, + message: running + ? "ADE service scheduled task is running." + : "ADE service scheduled task is installed.", + }; + } + const startupResult = run("reg.exe", buildWindowsRunKeyQueryArgs(taskName), { + encoding: "utf8", + windowsHide: true, + }); + if (startupResult.status === 0) { + const supervisor = queryWindowsSupervisor(run, launcherPath, pidPath); + return { + ok: supervisor.error == null, + serviceName, + action: "status", + installed: true, + running: supervisor.error ? null : supervisor.running, + path: taskName, + message: supervisor.error + ?? (supervisor.running + ? "ADE per-user background service is running." + : "ADE per-user startup entry is installed, but the background service is not running."), + }; + } + if (taskResult.status !== TASK_NOT_FOUND_EXIT_CODE) { + return { + ok: false, + serviceName, + action: "status", + installed: null, + running: null, + path: taskName, + message: serviceManagerResultText(taskResult) || "Unable to query the legacy ADE scheduled task.", + }; + } + if (startupResult.status !== REGISTRY_VALUE_NOT_FOUND_EXIT_CODE) { + return { + ok: false, + serviceName, action: "status", - installed: false, - running: false, - path: TASK_NAME, - message: serviceManagerResultText(result) || "ADE service scheduled task is not installed.", + installed: null, + running: null, + path: taskName, + message: serviceManagerResultText(startupResult) || "Unable to query the ADE per-user startup entry.", }; } - const running = isSchtasksOutputRunning(result.stdout); return { ok: true, - serviceName: ADE_RUNTIME_SERVICE_NAME, + serviceName, action: "status", - installed: true, - running, - path: TASK_NAME, - message: running - ? "ADE service scheduled task is running." - : "ADE service scheduled task is installed.", + installed: false, + running: false, + path: taskName, + message: "ADE background service startup entry is not installed.", }; } diff --git a/apps/ade-cli/src/services/projects/machineLayout.test.ts b/apps/ade-cli/src/services/projects/machineLayout.test.ts index 5e033a95e..63c25e3ea 100644 --- a/apps/ade-cli/src/services/projects/machineLayout.test.ts +++ b/apps/ade-cli/src/services/projects/machineLayout.test.ts @@ -1,31 +1,131 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { resolveMachineAdeLayout } from "./machineLayout"; describe("resolveMachineAdeLayout", () => { - it("keeps the stable Windows runtime pipe name for the default ADE home", () => { - const layout = resolveMachineAdeLayout( - { ADE_HOME: "/Users/arul/.ade" }, + const arulEnv = { + USERDOMAIN: "ADEBOX", + USERNAME: "arul", + USERPROFILE: "C:\\Users\\arul", + }; + + it("derives stable Windows pipe names from the canonical ADE home and user identity", () => { + const first = resolveMachineAdeLayout( + { ...arulEnv, ADE_HOME: "C:\\Users\\arul\\.ade" }, + "win32", + ); + const equivalent = resolveMachineAdeLayout( + { ...arulEnv, ADE_HOME: "C:/Users/arul/.ade" }, "win32", ); - expect(layout.socketPath).toBe("\\\\.\\pipe\\ade-runtime"); + expect(first.socketPath).toMatch(/^\\\\\.\\pipe\\ade-runtime-stable-[a-f0-9]{16}$/); + expect(equivalent.socketPath).toBe(first.socketPath); + expect(first.desktopBridgeSocketPath).toMatch( + /^\\\\\.\\pipe\\ade-desktop-bridge-stable-[a-f0-9]{16}$/, + ); + expect(equivalent.desktopBridgeSocketPath).toBe(first.desktopBridgeSocketPath); }); - it("uses distinct Windows runtime pipes for channel ADE homes", () => { + it("uses distinct Windows runtime pipes for release channels", () => { const alpha = resolveMachineAdeLayout( - { ADE_HOME: "/Users/arul/.ade-alpha" }, + { + ...arulEnv, + ADE_HOME: "C:\\Users\\arul\\.ade-alpha", + ADE_PACKAGE_CHANNEL: "alpha", + }, "win32", ); const beta = resolveMachineAdeLayout( - { ADE_HOME: "/Users/arul/.ade-beta" }, + { + ...arulEnv, + ADE_HOME: "C:\\Users\\arul\\.ade-beta", + ADE_PACKAGE_CHANNEL: "beta", + }, + "win32", + ); + + expect(alpha.socketPath).not.toBe(beta.socketPath); + expect(alpha.socketPath).toContain("ade-runtime-alpha-"); + expect(beta.socketPath).toContain("ade-runtime-beta-"); + }); + + it("isolates Windows runtime and desktop-bridge pipes for different users", () => { + const arul = resolveMachineAdeLayout( + { ...arulEnv, ADE_HOME: "D:\\Shared\\ADE" }, + "win32", + ); + const other = resolveMachineAdeLayout( + { + USERDOMAIN: "ADEBOX", + USERNAME: "other", + USERPROFILE: "C:\\Users\\other", + ADE_HOME: "D:\\Shared\\ADE", + }, "win32", ); - expect(alpha.socketPath).toBe("\\\\.\\pipe\\ade-runtime-ade-alpha"); - expect(beta.socketPath).toBe("\\\\.\\pipe\\ade-runtime-ade-beta"); + expect(arul.socketPath).not.toBe(other.socketPath); + expect(arul.desktopBridgeSocketPath).not.toBe(other.desktopBridgeSocketPath); }); + it("prefers a provided Windows SID over mutable account labels", () => { + const first = resolveMachineAdeLayout( + { + ...arulEnv, + ADE_WINDOWS_USER_SID: "S-1-5-21-1000", + ADE_HOME: "D:\\Shared\\ADE", + }, + "win32", + ); + const renamed = resolveMachineAdeLayout( + { + USERDOMAIN: "NEWDOMAIN", + USERNAME: "renamed", + USERPROFILE: "C:\\Users\\renamed", + ADE_WINDOWS_USER_SID: "S-1-5-21-1000", + ADE_HOME: "D:\\Shared\\ADE", + }, + "win32", + ); + + expect(renamed.socketPath).toBe(first.socketPath); + expect(renamed.desktopBridgeSocketPath).toBe(first.desktopBridgeSocketPath); + }); + + (process.platform === "win32" ? it : it.skip)( + "canonicalizes existing Windows ancestor casing before ADE_HOME is created", + () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-machine-layout-case-")); + const mixedCaseParent = path.join(tempRoot, "MiXeD-Parent"); + fs.mkdirSync(mixedCaseParent); + const canonicalHome = path.join(mixedCaseParent, "Missing-ADE-Home"); + const alternateHome = path.join( + tempRoot, + "mixed-parent", + "Missing-ADE-Home", + ); + try { + const canonical = resolveMachineAdeLayout( + { ...arulEnv, ADE_HOME: canonicalHome }, + "win32", + ); + const alternate = resolveMachineAdeLayout( + { ...arulEnv, ADE_HOME: alternateHome }, + "win32", + ); + + expect(alternate.socketPath).toBe(canonical.socketPath); + expect(alternate.desktopBridgeSocketPath).toBe(canonical.desktopBridgeSocketPath); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, + ); + it("derives the desktop-bridge socket from the ADE home", () => { const stable = resolveMachineAdeLayout( { ADE_HOME: "/Users/arul/.ade" }, @@ -35,20 +135,29 @@ describe("resolveMachineAdeLayout", () => { { ADE_HOME: "/Users/arul/.ade-beta" }, "darwin", ); - expect(stable.desktopBridgeSocketPath).toBe("/Users/arul/.ade/sock/desktop-bridge.sock"); - expect(beta.desktopBridgeSocketPath).toBe("/Users/arul/.ade-beta/sock/desktop-bridge.sock"); + expect(stable.desktopBridgeSocketPath).toBe( + path.join(path.resolve("/Users/arul/.ade"), "sock", "desktop-bridge.sock"), + ); + expect(beta.desktopBridgeSocketPath).toBe( + path.join(path.resolve("/Users/arul/.ade-beta"), "sock", "desktop-bridge.sock"), + ); }); it("uses distinct Windows desktop-bridge pipes for channel ADE homes", () => { const stable = resolveMachineAdeLayout( - { ADE_HOME: "/Users/arul/.ade" }, + { ...arulEnv, ADE_HOME: "C:\\Users\\arul\\.ade" }, "win32", ); const beta = resolveMachineAdeLayout( - { ADE_HOME: "/Users/arul/.ade-beta" }, + { + ...arulEnv, + ADE_HOME: "C:\\Users\\arul\\.ade-beta", + ADE_PACKAGE_CHANNEL: "beta", + }, "win32", ); - expect(stable.desktopBridgeSocketPath).toBe("\\\\.\\pipe\\ade-desktop-bridge"); - expect(beta.desktopBridgeSocketPath).toBe("\\\\.\\pipe\\ade-desktop-bridge-ade-beta"); + expect(stable.desktopBridgeSocketPath).not.toBe(beta.desktopBridgeSocketPath); + expect(stable.desktopBridgeSocketPath).toContain("ade-desktop-bridge-stable-"); + expect(beta.desktopBridgeSocketPath).toContain("ade-desktop-bridge-beta-"); }); }); diff --git a/apps/ade-cli/src/services/projects/machineLayout.ts b/apps/ade-cli/src/services/projects/machineLayout.ts index 071b57495..c02a9658a 100644 --- a/apps/ade-cli/src/services/projects/machineLayout.ts +++ b/apps/ade-cli/src/services/projects/machineLayout.ts @@ -1,4 +1,6 @@ import os from "node:os"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; import path from "node:path"; export type MachineAdeLayout = { @@ -28,16 +30,89 @@ export function resolveMachineAdeDir(env: NodeJS.ProcessEnv = process.env): stri return path.join(os.homedir(), ".ade"); } -function windowsPipePathForAdeDir(adeDir: string): string { - const homeName = path.basename(adeDir).replace(/[^a-zA-Z0-9_-]+/g, "-"); - if (!homeName || homeName === "-ade") return "\\\\.\\pipe\\ade-runtime"; - return `\\\\.\\pipe\\ade-runtime-${homeName.replace(/^-+/, "")}`; +function windowsUserIdentity(env: NodeJS.ProcessEnv): string { + const sid = env.ADE_WINDOWS_USER_SID?.trim() || env.USER_SID?.trim(); + if (sid) return `sid:${sid.toLowerCase()}`; + const username = env.USERNAME?.trim(); + const domain = env.USERDOMAIN?.trim(); + if (username) { + return `account:${domain ? `${domain}\\` : ""}${username}`.toLowerCase(); + } + const profile = env.USERPROFILE?.trim(); + if (profile) { + return `profile:${path.win32.resolve(profile).toLowerCase()}`; + } + const userInfo = os.userInfo(); + return `fallback:${userInfo.username}\0${userInfo.homedir}`.toLowerCase(); } -function windowsDesktopBridgePipePathForAdeDir(adeDir: string): string { - const homeName = path.basename(adeDir).replace(/[^a-zA-Z0-9_-]+/g, "-"); - if (!homeName || homeName === "-ade") return "\\\\.\\pipe\\ade-desktop-bridge"; - return `\\\\.\\pipe\\ade-desktop-bridge-${homeName.replace(/^-+/, "")}`; +function windowsChannelIdentity(adeDir: string, env: NodeJS.ProcessEnv): { + identity: string; + label: string; +} { + const serviceName = env.ADE_RUNTIME_SERVICE_NAME?.trim().toLowerCase(); + const explicitChannel = env.ADE_PACKAGE_CHANNEL?.trim().toLowerCase(); + const homeName = path.win32.basename(adeDir).toLowerCase(); + const inferred = homeName === ".ade-alpha" + ? "alpha" + : homeName === ".ade-beta" + ? "beta" + : homeName === ".ade" + ? "stable" + : "custom"; + const label = explicitChannel === "alpha" || explicitChannel === "beta" + ? explicitChannel + : explicitChannel === "stable" + ? "stable" + : serviceName?.endsWith(".alpha") + ? "alpha" + : serviceName?.endsWith(".beta") + ? "beta" + : serviceName === "com.ade.runtime" + ? "stable" + : inferred; + return { + identity: serviceName || explicitChannel || inferred, + label, + }; +} + +function canonicalWindowsPath(value: string): string { + const original = path.win32.resolve(value).replace(/\//g, "\\"); + const missingParts: string[] = []; + let cursor = original; + for (;;) { + try { + return path.win32.join(fs.realpathSync.native(cursor), ...missingParts); + } catch { + const parent = path.win32.dirname(cursor); + if (parent === cursor) return original; + missingParts.unshift(path.win32.basename(cursor)); + cursor = parent; + } + } +} + +function windowsPipeIdentity( + adeDir: string, + env: NodeJS.ProcessEnv, +): { channelLabel: string; hash: string } { + const canonicalAdeDir = canonicalWindowsPath(adeDir); + const channel = windowsChannelIdentity(canonicalAdeDir, env); + const hash = createHash("sha256") + .update(`${canonicalAdeDir}\0${channel.identity}\0${windowsUserIdentity(env)}`) + .digest("hex") + .slice(0, 16); + return { channelLabel: channel.label, hash }; +} + +function windowsPipePath( + prefix: "ade-runtime" | "ade-desktop-bridge", + adeDir: string, + env: NodeJS.ProcessEnv, +): string { + const identity = windowsPipeIdentity(adeDir, env); + return `\\\\.\\pipe\\${prefix}-${identity.channelLabel}-${identity.hash}`; } export function resolveMachineAdeLayout( @@ -45,13 +120,14 @@ export function resolveMachineAdeLayout( platform: NodeJS.Platform = process.platform, ): MachineAdeLayout { const adeDir = resolveMachineAdeDir(env); + const pipeAdeDir = env.ADE_HOME?.trim() || adeDir; const secretsDir = path.join(adeDir, "secrets"); const sockDir = path.join(adeDir, "sock"); const socketPath = platform === "win32" - ? windowsPipePathForAdeDir(adeDir) + ? windowsPipePath("ade-runtime", pipeAdeDir, env) : path.join(sockDir, "ade.sock"); const desktopBridgeSocketPath = platform === "win32" - ? windowsDesktopBridgePipePathForAdeDir(adeDir) + ? windowsPipePath("ade-desktop-bridge", pipeAdeDir, env) : path.join(sockDir, "desktop-bridge.sock"); return { adeDir, diff --git a/apps/ade-cli/src/services/projects/projectRegistry.ts b/apps/ade-cli/src/services/projects/projectRegistry.ts index edc1b0f7e..db9bd9db4 100644 --- a/apps/ade-cli/src/services/projects/projectRegistry.ts +++ b/apps/ade-cli/src/services/projects/projectRegistry.ts @@ -83,6 +83,7 @@ function readGitOriginUrl(rootPath: string): string | null { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, + windowsHide: true, }); if (result.status !== 0) return null; const value = result.stdout.trim(); diff --git a/apps/ade-cli/src/services/runtime/brainLoopWatchdog.test.ts b/apps/ade-cli/src/services/runtime/brainLoopWatchdog.test.ts index 988285072..8201be35c 100644 --- a/apps/ade-cli/src/services/runtime/brainLoopWatchdog.test.ts +++ b/apps/ade-cli/src/services/runtime/brainLoopWatchdog.test.ts @@ -12,6 +12,7 @@ import { evaluateBrainLoopWatchdog, readBrainLoopWatchdogLastWedge, recoverBrainLoopWatchdogBreadcrumb, + resolveBrainLoopWatchdogThresholdMs, startBrainLoopWatchdog, } from "./brainLoopWatchdog"; @@ -75,6 +76,12 @@ describe("brainLoopWatchdog", () => { }); }); + it("allows Windows background work more time before declaring the brain wedged", () => { + expect(resolveBrainLoopWatchdogThresholdMs(undefined, "win32")).toBe(60_000); + expect(resolveBrainLoopWatchdogThresholdMs(undefined, "darwin")).toBe(30_000); + expect(resolveBrainLoopWatchdogThresholdMs("45000", "win32")).toBe(45_000); + }); + it("renames a crash breadcrumb to last-wedge and emits the recovery warning", () => { const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-loop-watchdog-")); const breadcrumb = { diff --git a/apps/ade-cli/src/services/runtime/brainLoopWatchdog.ts b/apps/ade-cli/src/services/runtime/brainLoopWatchdog.ts index 656b72338..389e6a3a7 100644 --- a/apps/ade-cli/src/services/runtime/brainLoopWatchdog.ts +++ b/apps/ade-cli/src/services/runtime/brainLoopWatchdog.ts @@ -4,6 +4,7 @@ import { monitorEventLoopDelay } from "node:perf_hooks"; import { Worker } from "node:worker_threads"; export const DEFAULT_BRAIN_LOOP_WATCHDOG_MS = 30_000; +export const DEFAULT_WINDOWS_BRAIN_LOOP_WATCHDOG_MS = 60_000; export const BRAIN_LOOP_WATCHDOG_HEARTBEAT_MS = 1_000; export const BRAIN_LOOP_WATCHDOG_NEAR_MISS_MS = 2_000; export const BRAIN_LOOP_WATCHDOG_BREADCRUMB_FILE = "event-loop-wedge.json"; @@ -90,9 +91,16 @@ export function evaluateBrainLoopWatchdog(args: { }; } -function parseWatchdogThresholdMs(raw: string | undefined): number { +export function resolveBrainLoopWatchdogThresholdMs( + raw: string | undefined, + platform = process.platform, +): number { const parsed = Number.parseInt(raw?.trim() ?? "", 10); - if (!Number.isFinite(parsed)) return DEFAULT_BRAIN_LOOP_WATCHDOG_MS; + if (!Number.isFinite(parsed)) { + return platform === "win32" + ? DEFAULT_WINDOWS_BRAIN_LOOP_WATCHDOG_MS + : DEFAULT_BRAIN_LOOP_WATCHDOG_MS; + } return Math.max(BRAIN_LOOP_WATCHDOG_HEARTBEAT_MS, parsed); } @@ -329,7 +337,7 @@ export function startBrainLoopWatchdog(args: { if (env.ADE_DISABLE_LOOP_WATCHDOG === "1") return () => {}; if (!args.forceInTests && (env.VITEST || env.NODE_ENV === "test")) return () => {}; - const thresholdMs = parseWatchdogThresholdMs(env.ADE_LOOP_WATCHDOG_MS); + const thresholdMs = resolveBrainLoopWatchdogThresholdMs(env.ADE_LOOP_WATCHDOG_MS); const reportPath = path.join(args.runtimeDir, BRAIN_LOOP_WATCHDOG_REPORT_FILE); const reportSignal = "SIGUSR2"; let reportEnabled = false; diff --git a/apps/ade-cli/src/services/runtime/localIpcListenOptions.ts b/apps/ade-cli/src/services/runtime/localIpcListenOptions.ts new file mode 100644 index 000000000..cbf91abf6 --- /dev/null +++ b/apps/ade-cli/src/services/runtime/localIpcListenOptions.ts @@ -0,0 +1,24 @@ +import type { ListenOptions } from "node:net"; + +function isWindowsNamedPipePath(socketPath: string): boolean { + const normalized = socketPath.trim().replace(/\//g, "\\").toLowerCase(); + return normalized.startsWith("\\\\.\\pipe\\"); +} + +/** + * Make the intended-user-only Windows named-pipe boundary explicit. + * + * Node defaults both flags to false, but spelling them out prevents a future + * listener refactor from accidentally opting into a pipe readable or writable + * by every local Windows user. + */ +export function localIpcListenOptions( + socketPath: string, +): string | ListenOptions { + if (!isWindowsNamedPipePath(socketPath)) return socketPath; + return { + path: socketPath, + readableAll: false, + writableAll: false, + }; +} diff --git a/apps/ade-cli/src/services/runtime/socketSpawnLock.ts b/apps/ade-cli/src/services/runtime/socketSpawnLock.ts index 443285087..4db2ab6dc 100644 --- a/apps/ade-cli/src/services/runtime/socketSpawnLock.ts +++ b/apps/ade-cli/src/services/runtime/socketSpawnLock.ts @@ -1,5 +1,7 @@ +import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { resolveMachineAdeLayout } from "../projects/machineLayout"; /** * Cross-process mutual exclusion for "spawn a brain for this socket". @@ -88,9 +90,40 @@ function unlinkSocketSpawnLockIfOwner(lockPath: string, ownerId: string | null): } } +function isWindowsNamedPipePath(socketPath: string): boolean { + return socketPath + .trim() + .replace(/\//g, "\\") + .toLowerCase() + .startsWith("\\\\.\\pipe\\"); +} + +export function socketSpawnLockPath(socketPath: string): string { + if (isWindowsNamedPipePath(socketPath)) { + // A named pipe is a kernel namespace, not a filesystem directory. Trying + // to create `\\.\pipe\.spawn.lock` fails with ENOENT before a cold + // Windows runtime can be spawned. Keep the advisory lock in ADE's + // per-user runtime directory and hash the case-insensitive pipe identity. + const normalizedPipe = socketPath.trim().replace(/\//g, "\\").toLowerCase(); + const key = createHash("sha256") + .update(normalizedPipe) + .digest("hex") + .slice(0, 32); + return path.join( + resolveMachineAdeLayout().runtimeDir, + "spawn-locks", + `${key}.lock`, + ); + } + return path.join( + path.dirname(socketPath), + `${path.basename(socketPath)}.spawn.lock`, + ); +} + export async function withSocketSpawnLock(socketPath: string, task: () => Promise): Promise { if (socketPath.startsWith("tcp://")) return await task(); - const lockPath = path.join(path.dirname(socketPath), `${path.basename(socketPath)}.spawn.lock`); + const lockPath = socketSpawnLockPath(socketPath); fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 }); const deadline = Date.now() + 10_000; const owner = createSocketSpawnLockOwner(); diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 64a883d44..61d1e5509 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -4086,7 +4086,7 @@ describe("sync host account authentication", () => { ); expect(signedOutRejected.payload).toMatchObject({ code: "auth_failed", - message: expect.stringMatching(/not signed in.*Sign in on the Mac/i), + message: expect.stringMatching(/not signed in.*Sign in on this computer/i), }); const pinClient = await openAccountClient(port); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 16a777515..0c8bdf036 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -3923,7 +3923,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { `--tcp=${port}`, target, ]; - void execFileAsync(cli, cliArgs, { timeout: 10_000 }) + void execFileAsync(cli, cliArgs, { timeout: 10_000, windowsHide: true }) .then(({ stdout, stderr }) => { if (tailnetServeActivePublishToken !== publishToken) return; tailnetServeLastFailureSignature = null; @@ -4018,7 +4018,11 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const cli = resolveTailscaleCliPath(); let stale: number[]; try { - const { stdout } = await execFileAsync(cli, ["serve", "status", "--json"], { timeout: 10_000 }); + const { stdout } = await execFileAsync( + cli, + ["serve", "status", "--json"], + { timeout: 10_000, windowsHide: true }, + ); stale = staleAdeTailnetServePorts(stdout, currentPort); } catch { // No Tailscale, no permission, unparseable output: publishing the current @@ -4045,7 +4049,11 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // between the snapshot and this `off` is closed too. if (await isLocalPortServing(port)) continue; try { - await execFileAsync(cli, ["serve", `--tcp=${port}`, "off"], { timeout: 10_000 }); + await execFileAsync( + cli, + ["serve", `--tcp=${port}`, "off"], + { timeout: 10_000, windowsHide: true }, + ); reclaimed += 1; } catch { // A single stubborn entry must not stop the rest. @@ -4085,7 +4093,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { await execFileAsync( cli, ["serve", `--tcp=${servePort}`, "off"], - { timeout: 10_000 }, + { timeout: 10_000, windowsHide: true }, ); updateTailnetDiscoveryStatus({ state: "disabled", @@ -7248,13 +7256,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { deviceId: accountAuth.deviceId, }); return authFail( - "This machine is not signed in to an ADE account. Sign in on the Mac, then try again.", + "This machine is not signed in to an ADE account. Sign in on this computer, then try again.", ); } const config = args.getAccountAttestationConfig?.(); if (!config) { return authFail( - "This machine cannot verify ADE accounts. Update ADE on the Mac, then try again.", + "This machine cannot verify ADE accounts. Update ADE on this computer, then try again.", ); } const attestation = await verifyAccountAttestation({ @@ -7304,7 +7312,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); return authFail( "This device's saved pairing predates device-key security." - + " Remove it on the Mac and pair it again.", + + " Remove it on this computer and pair it again.", ); } const dpopFailure = evaluatePairedHelloDpop({ diff --git a/apps/ade-cli/src/services/sync/syncHostSingleton.test.ts b/apps/ade-cli/src/services/sync/syncHostSingleton.test.ts index 5e8401d49..0b1af000f 100644 --- a/apps/ade-cli/src/services/sync/syncHostSingleton.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostSingleton.test.ts @@ -52,6 +52,7 @@ function owner(overrides: Partial = {}): SyncHostSinglet socketPath: path.join(os.homedir(), ".ade", "sock", "ade.sock"), projectRoot: "/Users/admin/Projects/ADE", commandLine: "/Applications/ADE.app/Contents/MacOS/ADE /Applications/ADE.app/Contents/Resources/ade-cli/cli.cjs serve", + processStartedAt: "2026-06-09T00:00:00.000Z", quitCommand: `ADE_HOME='${path.join(os.homedir(), ".ade")}' '/Applications/ADE.app/Contents/Resources/ade-cli/bin/ade' brain stop --text`, createdAt: now, updatedAt: now, @@ -78,6 +79,7 @@ describe("sync host singleton", () => { lockPath, pidAlive: (pid) => pid === lockOwner.pid, scanListeners: () => [], + platform: "darwin", }); expect(conflict).toMatchObject({ @@ -255,6 +257,44 @@ describe("isSameChannelSyncHostOwner", () => { }); describe("buildQuitCommand (launch-gate stop command)", () => { + it("uses a PowerShell-native process stop on Windows", () => { + const command = buildQuitCommand({ + pid: 4242, + commandLine: "C:\\Program Files\\ADE\\ADE.exe cli.cjs serve", + appName: "ADE", + packageChannel: null, + adeHome: "C:\\Users\\example\\.ade", + platform: "win32", + }); + expect(command).toBe( + "Stop-Process -Id 4242 -Force -ErrorAction SilentlyContinue", + ); + expect(command).not.toContain("launchctl"); + expect(command).not.toContain("/bin/kill"); + }); + + it("clears a Windows lock when the PID was reused by another process", () => { + const lockPath = tempLockPath(); + const lockOwner = owner({ + pid: 21_556, + socketPath: "\\\\.\\pipe\\ade-runtime-dev-test", + commandLine: + "C:\\Program Files\\nodejs\\node.exe C:\\dev\\ADE\\apps\\ade-cli\\dist\\cli.cjs serve --socket \\\\.\\pipe\\ade-runtime-dev-test", + }); + writeLock(lockPath, lockOwner); + + const conflict = detectSyncHostSingletonConflict({ + lockPath, + pidAlive: () => true, + processMatchesOwner: () => false, + scanListeners: () => [], + platform: "win32", + }); + + expect(conflict).toBeNull(); + expect(fs.existsSync(lockPath)).toBe(false); + }); + it("stops a launchd-managed brain via launchctl bootout, not a hardcoded app path", () => { const command = buildQuitCommand({ pid: 4242, @@ -262,6 +302,7 @@ describe("buildQuitCommand (launch-gate stop command)", () => { appName: "ADE", packageChannel: null, adeHome: "/Users/example/.ade", + platform: "darwin", }); expect(command).toContain("launchctl bootout gui/$(id -u)/com.ade.runtime"); expect(command).toContain("/bin/kill 4242"); @@ -273,10 +314,10 @@ describe("buildQuitCommand (launch-gate stop command)", () => { it("derives the per-channel launchd label", () => { expect( - buildQuitCommand({ pid: 1, commandLine: null, appName: "ADE Beta", packageChannel: "beta", adeHome: null }), + buildQuitCommand({ pid: 1, commandLine: null, appName: "ADE Beta", packageChannel: "beta", adeHome: null, platform: "darwin" }), ).toContain("com.ade.runtime.beta"); expect( - buildQuitCommand({ pid: 1, commandLine: null, appName: "ADE Alpha", packageChannel: "alpha", adeHome: null }), + buildQuitCommand({ pid: 1, commandLine: null, appName: "ADE Alpha", packageChannel: "alpha", adeHome: null, platform: "darwin" }), ).toContain("com.ade.runtime.alpha"); }); @@ -288,6 +329,7 @@ describe("buildQuitCommand (launch-gate stop command)", () => { packageChannel: null, adeHome: null, serviceName: "com.ade.runtime.custom", + platform: "darwin", }); expect(command).toContain("launchctl bootout gui/$(id -u)/com.ade.runtime.custom"); }); @@ -300,6 +342,7 @@ describe("buildQuitCommand (launch-gate stop command)", () => { appName: "ADE Alpha", packageChannel: null, adeHome: null, + platform: "darwin", }); expect(command).toContain("launchctl bootout gui/$(id -u)/com.ade.runtime.alpha"); expect(command).not.toContain("/Applications/"); diff --git a/apps/ade-cli/src/services/sync/syncHostSingleton.ts b/apps/ade-cli/src/services/sync/syncHostSingleton.ts index e0d282beb..91a0638ba 100644 --- a/apps/ade-cli/src/services/sync/syncHostSingleton.ts +++ b/apps/ade-cli/src/services/sync/syncHostSingleton.ts @@ -17,6 +17,8 @@ export type SyncHostSingletonOwner = { socketPath: string | null; projectRoot: string | null; commandLine: string | null; + /** Stable-enough birth identity used with pid/executable to reject PID reuse. */ + processStartedAt?: string | null; quitCommand: string; createdAt: string; updatedAt: string; @@ -41,7 +43,9 @@ export type SyncHostSingletonLease = { export type SyncHostSingletonDeps = { lockPath?: string; pidAlive?: (pid: number) => boolean; + processMatchesOwner?: (owner: SyncHostSingletonOwner) => boolean | null; scanListeners?: () => SyncHostSingletonOwner[]; + platform?: NodeJS.Platform; }; // Which leases THIS process currently holds. The lock file answers "who owns @@ -95,9 +99,23 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } -function withPidKillFallback(command: string, pid: number): string { +function windowsPidStopCommand(pid: number): string { + return `Stop-Process -Id ${Math.floor(pid)} -Force -ErrorAction SilentlyContinue`; +} + +function withPidKillFallback( + command: string, + pid: number, + platform: NodeJS.Platform = process.platform, +): string { if (!Number.isFinite(pid) || pid <= 0) return command; const normalizedPid = Math.floor(pid); + if (platform === "win32") { + if (command.includes(`Stop-Process -Id ${normalizedPid}`)) return command; + // Windows releases before the native port could persist POSIX recovery + // commands. Replace those instead of copying another unusable command. + return windowsPidStopCommand(normalizedPid); + } if (command.includes(`/bin/kill ${normalizedPid}`)) return command; return `${command}; /bin/kill ${normalizedPid} 2>/dev/null || true`; } @@ -130,7 +148,78 @@ function defaultPidAlive(pid: number): boolean { } } -function safeReadLock(lockPath: string): SyncHostSingletonLockFile | null { +function executableFromCommandLine(commandLine: string | null): string | null { + const match = commandLine?.trim().match(/^(?:"([^"]+)"|(.+?\.exe))(?=\s|$)/i); + const executable = match?.[1] ?? match?.[2] ?? null; + return executable ? path.win32.basename(executable).toLowerCase() : null; +} + +function defaultProcessMatchesOwner( + owner: SyncHostSingletonOwner, + platform: NodeJS.Platform = process.platform, +): boolean | null { + if (platform !== "win32") return null; + const script = [ + `$target = Get-Process -Id ${Math.floor(owner.pid)} -ErrorAction SilentlyContinue`, + "if ($null -eq $target) { exit 3 }", + "$executablePath = $null", + "$startedAt = $null", + "try { $executablePath = $target.Path } catch {}", + "try { $startedAt = $target.StartTime.ToUniversalTime().ToString('o') } catch {}", + "[Console]::Out.Write((@{ executablePath = $executablePath; startedAt = $startedAt } | ConvertTo-Json -Compress))", + ].join("; "); + let raw = ""; + try { + raw = execFileSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", script], + { + encoding: "utf8", + timeout: 2_000, + maxBuffer: 64 * 1024, + windowsHide: true, + }, + ); + } catch { + // If process inspection is unavailable, remain conservative and preserve + // the lock rather than risking two live sync hosts. + return null; + } + try { + const parsed = JSON.parse(raw) as { + executablePath?: unknown; + startedAt?: unknown; + }; + const expectedExecutable = executableFromCommandLine(owner.commandLine); + const actualExecutable = typeof parsed.executablePath === "string" && parsed.executablePath.trim() + ? path.win32.basename(parsed.executablePath.trim()).toLowerCase() + : null; + if (expectedExecutable && actualExecutable && expectedExecutable !== actualExecutable) { + return false; + } + const expectedStartedAtMs = owner.processStartedAt + ? Date.parse(owner.processStartedAt) + : Number.NaN; + const actualStartedAtMs = typeof parsed.startedAt === "string" + ? Date.parse(parsed.startedAt) + : Number.NaN; + if ( + Number.isFinite(expectedStartedAtMs) + && Number.isFinite(actualStartedAtMs) + && Math.abs(expectedStartedAtMs - actualStartedAtMs) > 2_000 + ) { + return false; + } + return true; + } catch { + return null; + } +} + +function safeReadLock( + lockPath: string, + platform: NodeJS.Platform = process.platform, +): SyncHostSingletonLockFile | null { try { const parsed = JSON.parse(fs.readFileSync(lockPath, "utf8")) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; @@ -158,7 +247,11 @@ function safeReadLock(lockPath: string): SyncHostSingletonLockFile | null { socketPath: typeof row.socketPath === "string" && row.socketPath.trim() ? row.socketPath : null, projectRoot: typeof row.projectRoot === "string" && row.projectRoot.trim() ? row.projectRoot : null, commandLine: typeof row.commandLine === "string" && row.commandLine.trim() ? row.commandLine : null, - quitCommand: withPidKillFallback(rawQuitCommand, pid), + processStartedAt: + typeof row.processStartedAt === "string" && Number.isFinite(Date.parse(row.processStartedAt)) + ? row.processStartedAt + : null, + quitCommand: withPidKillFallback(rawQuitCommand, pid, platform), createdAt: typeof row.createdAt === "string" && row.createdAt.trim() ? row.createdAt : new Date().toISOString(), updatedAt: typeof row.updatedAt === "string" && row.updatedAt.trim() ? row.updatedAt : new Date().toISOString(), }, @@ -214,7 +307,13 @@ export function buildQuitCommand(args: { packageChannel: string | null; adeHome: string | null; serviceName?: string | null; + platform?: NodeJS.Platform; }): string { + if ((args.platform ?? process.platform) === "win32") { + return Number.isFinite(args.pid) && args.pid > 0 + ? windowsPidStopCommand(args.pid) + : ""; + } const commandLine = args.commandLine ?? ""; const channel = normalizedChannel(args.packageChannel) ?? (/ADE Beta\.app|ade-beta|\bADE Beta\b/i.test(commandLine) ? "beta" : null) @@ -245,6 +344,9 @@ function currentOwner(args: { const appName = process.env.ADE_DESKTOP_APP_NAME?.trim() || defaultAppName(channel); const commandLine = commandLineText(); const serviceName = process.env.ADE_RUNTIME_SERVICE_NAME?.trim() || null; + const processStartedAt = new Date( + Date.now() - Math.max(0, process.uptime() * 1_000), + ).toISOString(); return { id: randomUUID(), pid: process.pid, @@ -256,6 +358,7 @@ function currentOwner(args: { socketPath: process.env.ADE_RUNTIME_SOCKET_PATH?.trim() || process.env.ADE_RPC_SOCKET_PATH?.trim() || null, projectRoot: args.projectRoot ? path.resolve(args.projectRoot) : null, commandLine, + processStartedAt, quitCommand: buildQuitCommand({ pid: process.pid, commandLine, @@ -295,6 +398,7 @@ function psCommandLines(pids: number[]): Map { const output = execFileSync("ps", ["-p", unique.join(","), "-o", "pid=,command="], { encoding: "utf8", timeout: 2_000, + windowsHide: true, }); const commands = new Map(); for (const line of output.split(/\r?\n/)) { @@ -347,6 +451,7 @@ function legacyOwner(pid: number, port: number, commandLine: string | null): Syn socketPath: null, projectRoot: null, commandLine, + processStartedAt: null, quitCommand: buildQuitCommand({ pid, commandLine, appName, packageChannel: channel, adeHome }), createdAt: now, updatedAt: now, @@ -365,6 +470,7 @@ function scanNativeSyncHostListeners(): SyncHostSingletonOwner[] { ], { encoding: "utf8", timeout: 2_000, + windowsHide: true, }); } catch (error) { output = typeof (error as { stdout?: unknown }).stdout === "string" @@ -389,21 +495,35 @@ function scanNativeSyncHostListeners(): SyncHostSingletonOwner[] { function activeLockConflict( lockPath: string, pidAlive: (pid: number) => boolean, + processMatchesOwner: (owner: SyncHostSingletonOwner) => boolean | null, + platform: NodeJS.Platform = process.platform, ): SyncHostSingletonConflict | null { - const lock = safeReadLock(lockPath); + const lock = safeReadLock(lockPath, platform); if (!lock) return null; if (lock.owner.pid === process.pid) return null; if (!pidAlive(lock.owner.pid)) { unlinkLock(lockPath); return null; } + if (processMatchesOwner(lock.owner) === false) { + // Windows can reuse a dead brain's PID after a reboot or crash. A live PID + // is not proof that it is still the process recorded in this lock. + unlinkLock(lockPath); + return null; + } return { reason: "lock", owner: lock.owner }; } export function detectSyncHostSingletonConflict( deps: SyncHostSingletonDeps = {}, ): SyncHostSingletonConflict | null { - const hasExplicitDeps = Boolean(deps.lockPath || deps.pidAlive || deps.scanListeners); + const hasExplicitDeps = Boolean( + deps.lockPath + || deps.pidAlive + || deps.processMatchesOwner + || deps.scanListeners + || deps.platform, + ); if ( isTestProcess() && process.env.ADE_SYNC_HOST_SINGLETON_TEST_MODE !== "1" && @@ -413,7 +533,14 @@ export function detectSyncHostSingletonConflict( } const lockPath = deps.lockPath ?? syncHostSingletonLockPath(); const pidAlive = deps.pidAlive ?? defaultPidAlive; - const lockConflict = activeLockConflict(lockPath, pidAlive); + const processMatchesOwner = deps.processMatchesOwner + ?? ((owner) => defaultProcessMatchesOwner(owner, deps.platform)); + const lockConflict = activeLockConflict( + lockPath, + pidAlive, + processMatchesOwner, + deps.platform, + ); if (lockConflict) return lockConflict; const listener = (deps.scanListeners ?? scanNativeSyncHostListeners)() .find((owner) => owner.pid !== process.pid && pidAlive(owner.pid)); @@ -459,13 +586,20 @@ export function acquireSyncHostSingleton( assertNoSyncHostSingletonConflict(deps); const lockPath = deps.lockPath ?? syncHostSingletonLockPath(); const owner = currentOwner(args); + const processMatchesOwner = deps.processMatchesOwner + ?? ((candidate) => defaultProcessMatchesOwner(candidate, deps.platform)); for (let attempt = 0; attempt < 2; attempt += 1) { try { writeLock(lockPath, owner, "wx"); break; } catch (error) { if ((error as NodeJS.ErrnoException | null | undefined)?.code !== "EEXIST") throw error; - const conflict = activeLockConflict(lockPath, deps.pidAlive ?? defaultPidAlive); + const conflict = activeLockConflict( + lockPath, + deps.pidAlive ?? defaultPidAlive, + processMatchesOwner, + deps.platform, + ); if (conflict) throw new SyncHostSingletonConflictError(conflict); unlinkLock(lockPath); if (attempt === 1) writeLock(lockPath, owner, "wx"); @@ -483,13 +617,13 @@ export function acquireSyncHostSingleton( updatedAt: new Date().toISOString(), }; Object.assign(owner, next); - const lock = safeReadLock(lockPath); + const lock = safeReadLock(lockPath, deps.platform); if (lock?.owner.id === owner.id && lock.owner.pid === process.pid) { writeLock(lockPath, owner, "w"); } }, dispose() { - const lock = safeReadLock(lockPath); + const lock = safeReadLock(lockPath, deps.platform); if (lock?.owner.id === owner.id && lock.owner.pid === process.pid) { unlinkLock(lockPath); } diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 5174e1101..8565747d9 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -1460,6 +1460,7 @@ export function createSyncService(args: SyncServiceArgs) { transferReadiness: options?.includeTransferReadiness === false ? (transferReadinessCache?.value ?? buildSkippedTransferReadiness()) : await getTransferReadiness({ force: options?.forceTransferReadiness === true }), + crdtSyncAvailable, survivableStateText: crdtSyncAvailable ? "Paused and idle state will remain available on the new host." @@ -1467,7 +1468,9 @@ export function createSyncService(args: SyncServiceArgs) { blockingStateText: crdtSyncAvailable ? "Live chats or terminals must stop first." - : "Install Windows cr-sqlite support before pairing or syncing devices.", + : process.platform === "win32" + ? "Phone sync is unavailable because crsqlite.dll could not be loaded. Reinstall ADE, then restart it before pairing a device." + : "Phone pairing is unavailable because the CRDT database extension is unavailable on this platform.", }; }, diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index 916b4d7a7..7b256517a 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -14,6 +14,11 @@ import { import { JsonRpcClient } from "../jsonRpcClient"; import { startTuiHeartbeat, type TuiHeartbeat } from "../heartbeat"; import { ProcessJsonRpcClient } from "../remoteBridge"; +import { + socketSpawnLockPath, + withSocketSpawnLock, +} from "../../services/runtime/socketSpawnLock"; +import { resolveMachineAdeLayout } from "../../services/projects/machineLayout"; import { appendDedupedTuiEvent, appendReservedTuiEvent, @@ -123,7 +128,15 @@ function useMissingMachineSocket(): string { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-machine-")); process.env.ADE_HOME = adeHome; delete process.env.ADE_RPC_SOCKET_PATH; - return path.join(adeHome, "sock", "ade.sock"); + return resolveMachineAdeLayout().socketPath; +} + +let nextTestPipeId = 1; + +function localTestSocketPath(tmpDir: string, fileName: string): string { + if (process.platform !== "win32") return path.join(tmpDir, fileName); + const stem = fileName.replace(/[^a-zA-Z0-9_-]+/g, "-"); + return `\\\\.\\pipe\\ade-code-${process.pid}-${nextTestPipeId++}-${stem}`; } function mockAttachedClient(): { @@ -255,7 +268,7 @@ describe("connectToAde embedded mode", () => { it("does not silently fall back to embedded mode when socket attach fails", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-missing-socket-")); - const socketPath = path.join(tmpDir, "missing.sock"); + const socketPath = localTestSocketPath(tmpDir, "missing.sock"); await expect(connectToAde({ project, @@ -267,7 +280,7 @@ describe("connectToAde embedded mode", () => { it("explains remote bridge failures without exposing its temporary socket path", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-remote-bridge-")); - const socketPath = path.join(tmpDir, "bridge.sock"); + const socketPath = localTestSocketPath(tmpDir, "bridge.sock"); try { await expect(connectToAde({ @@ -298,7 +311,7 @@ describe("connectToAde embedded mode", () => { it("rejects a direct socket whose runtime role is stale", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-stale-role-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: string[] = []; const server = net.createServer((socket) => { let buffer = ""; @@ -333,7 +346,7 @@ describe("connectToAde embedded mode", () => { it("allows remote sockets to differ by build hash and project root", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-remote-socket-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: string[] = []; const server = net.createServer((socket) => { let buffer = ""; @@ -377,7 +390,7 @@ describe("connectToAde embedded mode", () => { it("registers the project and injects projectId when attached to the machine daemon", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: Array<{ method: string; params?: Record }> = []; const server = net.createServer((socket) => { let buffer = ""; @@ -455,7 +468,7 @@ describe("connectToAde embedded mode", () => { it("promotes the project to a recent catalog row for an interactive launch", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: Array<{ method: string; params?: Record }> = []; const server = net.createServer((socket) => { let buffer = ""; @@ -512,7 +525,7 @@ describe("connectToAde embedded mode", () => { it("adapts multi-project runtime chat events into the TUI chat stream", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const serverSocketRef: { current: net.Socket | null } = { current: null }; const requests: Array<{ method: string; params?: Record }> = []; const server = net.createServer((socket) => { @@ -599,7 +612,7 @@ describe("connectToAde embedded mode", () => { it("surfaces runtime event replay gaps to subscribers", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-gap-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const server = net.createServer((socket) => { let buffer = ""; socket.on("error", () => {}); @@ -706,7 +719,7 @@ describe("connectToAde embedded mode", () => { expect(client.close).toHaveBeenCalledTimes(1); }); - it("rechecks the machine socket after taking the spawn lock", async () => { + it.skipIf(process.platform === "win32")("rechecks the machine socket after taking the spawn lock", async () => { const socketPath = useMissingMachineSocket(); const lockPath = path.join(path.dirname(socketPath), `${path.basename(socketPath)}.spawn.lock`); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); @@ -730,6 +743,23 @@ describe("connectToAde embedded mode", () => { expect(fs.existsSync(lockPath)).toBe(false); }); + it("keeps Windows named-pipe spawn locks in the per-user ADE runtime directory", async () => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-pipe-lock-")); + process.env.ADE_HOME = adeHome; + const socketPath = `\\\\.\\pipe\\ade-runtime-stable-${process.pid}`; + const lockPath = socketSpawnLockPath(socketPath); + let ran = false; + + await withSocketSpawnLock(socketPath, async () => { + ran = true; + expect(fs.existsSync(lockPath)).toBe(true); + }); + + expect(ran).toBe(true); + expect(path.dirname(lockPath)).toBe(path.join(adeHome, "runtime", "spawn-locks")); + expect(fs.existsSync(lockPath)).toBe(false); + }); + it("does not spawn a second brain while a recently spawned one is still coming up", async () => { // The spawn lock only serializes the first attempt. A brain that has not yet // bound its socket must not attract a rival spawn from the next `ade code`, @@ -765,7 +795,7 @@ describe("connectToAde embedded mode", () => { expect(childProcess.spawn).toHaveBeenCalledTimes(2); }); - it("unlinks stale machine socket files before retrying daemon startup", async () => { + it.skipIf(process.platform === "win32")("unlinks stale machine socket files before retrying daemon startup", async () => { const socketPath = useMissingMachineSocket(); fs.mkdirSync(path.dirname(socketPath), { recursive: true }); fs.writeFileSync(socketPath, ""); @@ -945,7 +975,7 @@ function closeServer(server: net.Server): Promise { describe("JsonRpcClient", () => { it("handles framed notifications before JSONL responses", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -986,7 +1016,7 @@ describe("JsonRpcClient", () => { it("honors byte-based Content-Length framing for unicode payloads", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1025,7 +1055,7 @@ describe("JsonRpcClient", () => { it("matches responses whose ids are echoed as strings", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); const server = net.createServer((socket) => { let buffer = ""; socket.on("data", (chunk) => { @@ -1061,7 +1091,7 @@ describe("JsonRpcClient", () => { it("handles large Content-Length frames split across many chunks", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1102,7 +1132,7 @@ describe("JsonRpcClient", () => { it("fires onClose when the socket drops unexpectedly", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1124,7 +1154,7 @@ describe("JsonRpcClient", () => { it("does not fire onClose on an intentional close()", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); const server = net.createServer(() => {}); await listenRpc(server, socketPath); const client = await JsonRpcClient.connect(socketPath); @@ -1142,7 +1172,7 @@ describe("JsonRpcClient", () => { it("times out pending requests by tearing down the socket", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1170,7 +1200,7 @@ describe("JsonRpcClient", () => { it("fails the connection on parse garbage instead of continuing", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; diff --git a/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts b/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts index c5ae353be..11ca61008 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts @@ -117,16 +117,16 @@ describe("copy ADE deeplink keybinding", () => { describe("clipboard helper dispatches the right OS command", () => { it("uses pbcopy on darwin with the deeplink as stdin", () => { - const calls: Array<{ cmd: string; args: string[]; input: string }> = []; + const calls: Array<{ cmd: string; args: string[]; input: string; windowsHide: boolean | undefined }> = []; const ok = copyToClipboard("ade://lane/abc", { platform: "darwin", spawn: (cmd, args, opts) => { - calls.push({ cmd, args, input: opts.input }); + calls.push({ cmd, args, input: opts.input, windowsHide: opts.windowsHide }); return { status: 0 }; }, }); expect(ok).toBe(true); - expect(calls).toEqual([{ cmd: "pbcopy", args: [], input: "ade://lane/abc" }]); + expect(calls).toEqual([{ cmd: "pbcopy", args: [], input: "ade://lane/abc", windowsHide: true }]); }); it("uses clip on win32", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts b/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts index 6d3945344..c3753202f 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts @@ -82,6 +82,9 @@ describe("startSyncRemoteBridge", () => { connectionLabel: "local network (studio.local:8787)", })), }); + if (process.platform === "win32") { + expect(bridge.socketUrl).toMatch(/^\\\\\.\\pipe\\ade-code-paired-/); + } const socket = bridge.socketUrl.startsWith("tcp://") ? net.connect(Number(new URL(bridge.socketUrl).port), "127.0.0.1") : net.connect(bridge.socketUrl); diff --git a/apps/ade-cli/src/tuiClient/__tests__/state.test.ts b/apps/ade-cli/src/tuiClient/__tests__/state.test.ts index 94463b7e6..7241a30df 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/state.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/state.test.ts @@ -17,25 +17,27 @@ afterEach(() => { describe("ade code persisted state", () => { it("prefers project-scoped lane and chat state over legacy global fallback", () => { + const repoA = path.resolve("/repo-a"); + const repoB = path.resolve("/repo-b"); const state = normalizeAdeCodeState({ lastChatByLane: { main: "legacy-chat" }, lastLaneId: "legacy-lane", lastChatByProjectLane: { - "/repo-a": { main: "repo-a-chat" }, - "/repo-b": { main: "repo-b-chat" }, + [repoA]: { main: "repo-a-chat" }, + [repoB]: { main: "repo-b-chat" }, }, lastLaneByProject: { - "/repo-a": "repo-a-lane", - "/repo-b": "repo-b-lane", + [repoA]: "repo-a-lane", + [repoB]: "repo-b-lane", }, draftKind: "chat", draftKindByProject: { - "/repo-a": "chat", - "/repo-b": "cli", + [repoA]: "chat", + [repoB]: "cli", }, }); - expect(scopedAdeCodeState(state, "/repo-b")).toEqual({ + expect(scopedAdeCodeState(state, repoB)).toEqual({ lastChatByLane: { main: "repo-b-chat" }, lastLaneId: "repo-b-lane", draftKind: "cli", diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 1016f0a05..7327d6173 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -12631,7 +12631,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return true; } if (!attachment) { - addNotice("No clipboard image was found. On macOS, copy an image or image file path; ADE Code checks pngpaste and pbpaste.", "error"); + const clipboardHint = process.platform === "win32" + ? "On Windows, copy an image or image file path; ADE Code reads the system clipboard through PowerShell." + : process.platform === "darwin" + ? "On macOS, copy an image or image file path; ADE Code checks pngpaste and pbpaste." + : "Copy an image or image file path; ADE Code checks wl-paste and xclip when available."; + addNotice(`No clipboard image was found. ${clipboardHint}`, "error"); return true; } if (activePaneRef.current !== "chat") { @@ -16710,7 +16715,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, : EMPTY_TERMINAL_CHUNKS; if (error && !connection) { - const remoteLabel = project.remoteLabel?.trim() || "the remote Mac"; + const remoteLabel = project.remoteLabel?.trim() || "the remote computer"; return ( @@ -16721,7 +16726,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, {error} {remoteLaunch ? ( - The remote Mac may be restarting; every retry re-evaluates its saved connection paths. + The remote computer may be restarting; every retry re-evaluates its saved connection paths. ) : null} diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index c01e12acc..60d3c5c30 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -880,7 +880,7 @@ export async function connectToAde(args: { const message = errorMessage(error); if (args.requireSocket) { if (args.remote) { - const remoteLabel = args.project.remoteLabel?.trim() || "the remote Mac"; + const remoteLabel = args.project.remoteLabel?.trim() || "the remote computer"; throw new Error( `Remote ADE connection to ${remoteLabel} was interrupted while ADE Code was starting: ` + `${remoteSocketFailureDetail(message, explicitSocketPath)}. ` + diff --git a/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts b/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts index d1a6fe492..383ca6ee8 100644 --- a/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts +++ b/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts @@ -64,7 +64,7 @@ export async function pairedRouteAccountProof(args: { const expectedOwnerUserId = args.credentials.accountOwnerUserId?.trim() ?? ""; if (expectedOwnerUserId && proof.userId.trim() !== expectedOwnerUserId) { throw new PairedRuntimeRelayAuthRequiredError( - "Sign in with the same ADE account as this Mac to connect through Relay. Local network and Tailscale connections still work without an account.", + "Sign in with the same ADE account as this computer to connect through Relay. Local network and Tailscale connections still work without an account.", ); } return { userId: proof.userId.trim(), token: proof.token.trim() }; @@ -78,7 +78,7 @@ export async function assertRelayAccountUnchanged( const currentProof = await getAccountRelayProof().catch(() => null); if (currentProof?.userId.trim() === initialProof.userId) return; throw new PairedRuntimeRelayAuthRequiredError( - "Your ADE account changed before the Relay connection finished. Sign in with the same account as this Mac and try again.", + "Your ADE account changed before the Relay connection finished. Sign in with the same account as this computer and try again.", ); } diff --git a/apps/ade-cli/src/tuiClient/remoteBridge.ts b/apps/ade-cli/src/tuiClient/remoteBridge.ts index 451915357..2500eab12 100644 --- a/apps/ade-cli/src/tuiClient/remoteBridge.ts +++ b/apps/ade-cli/src/tuiClient/remoteBridge.ts @@ -1,8 +1,10 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import net, { type AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; +import { localIpcListenOptions } from "../services/runtime/localIpcListenOptions"; import { RemoteTargetRegistry } from "../../../desktop/src/main/services/remoteRuntime/remoteTargetRegistry"; import type { RemoteRuntimeTarget, @@ -300,7 +302,11 @@ async function startLocalBridgeListener( if (bridgeDir) { try { fs.chmodSync(bridgeDir, 0o700); } catch {} } - const bridgeSocketPath = bridgeDir ? path.join(bridgeDir, "bridge.sock") : null; + const bridgeSocketPath = process.platform === "win32" + ? `\\\\.\\pipe\\${directoryPrefix}${process.pid}-${randomUUID()}` + : bridgeDir + ? path.join(bridgeDir, "bridge.sock") + : null; const server = net.createServer(onConnection); server.maxConnections = 1; const removeFiles = (): void => { @@ -322,7 +328,7 @@ async function startLocalBridgeListener( }; server.once("listening", onListening); server.once("error", onError); - if (bridgeSocketPath) server.listen(bridgeSocketPath); + if (bridgeSocketPath) server.listen(localIpcListenOptions(bridgeSocketPath)); else server.listen(0, "127.0.0.1"); }); } catch (error) { diff --git a/apps/ade-cli/src/tuiClient/remoteLauncher.ts b/apps/ade-cli/src/tuiClient/remoteLauncher.ts index 38c7644a9..2a44b97e3 100644 --- a/apps/ade-cli/src/tuiClient/remoteLauncher.ts +++ b/apps/ade-cli/src/tuiClient/remoteLauncher.ts @@ -252,10 +252,10 @@ export function parseRemoteAdeCodeArgs(argv: string[]): RemoteCliOptions { function printRemoteHelp(): void { process.stdout.write(`ade code remote -Connect ADE Code to a Mac already saved in ADE Connections. +Connect ADE Code to a computer already saved in ADE Connections. Local network and Tailscale connections work without an ADE account. ADE Relay -requires both Macs to be signed in to the same account. Advanced SSH is used +requires both computers to be signed in to the same account. Advanced SSH is used only when you explicitly save an SSH connection. Usage: @@ -1072,8 +1072,8 @@ export async function listRemoteSessions(client: RemoteRpcClientLike, projectId: async function selectTarget(targets: RemoteRuntimeTarget[], query: string | null): Promise { if (!targets.length) { throw new Error( - "No saved Macs yet. In ADE desktop, open Connections and choose Add machine. " + - "You can sign in to find your Macs, pair directly, scan your network, or use advanced SSH setup.", + "No saved computers yet. In ADE desktop, open Connections and choose Add machine. " + + "You can sign in to find your computers, pair directly, scan your network, or use advanced SSH setup.", ); } if (query) { @@ -1087,10 +1087,10 @@ async function selectTarget(targets: RemoteRuntimeTarget[], query: string | null const selectionMode = machineSelectionMode(targets.length, canPrompt()); if (selectionMode === "auto") return targets[0]!; if (selectionMode === "flag-required") { - throw new Error("Choose a Mac: pass --target non-interactively."); + throw new Error("Choose a computer: pass --target non-interactively."); } return await promptInteractiveChoice( - "Choose a Mac", + "Choose a computer", targets, remoteTargetChoiceLabel, ); @@ -1408,7 +1408,7 @@ export async function runAdeCodeRemote( ); if (target.transport !== "paired" && options.routePreference !== "auto") { throw new Error( - `--route ${options.routePreference} applies only to paired Macs. ` + + `--route ${options.routePreference} applies only to paired computers. ` + `${target.name} is configured for advanced SSH.`, ); } diff --git a/apps/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh new file mode 100644 index 000000000..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 d614daabd..98e578273 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/dev.cjs b/apps/desktop/scripts/dev.cjs index 94e0431a6..28a89c706 100644 --- a/apps/desktop/scripts/dev.cjs +++ b/apps/desktop/scripts/dev.cjs @@ -7,7 +7,14 @@ const path = require("node:path"); const projectRoot = path.resolve(__dirname, ".."); const distMainFile = path.join(projectRoot, "dist", "main", "main.cjs"); -const npxCommand = "npx"; +const mainReadyMarker = path.join( + projectRoot, + "dist", + `.ade-dev-main-ready-${process.pid}`, +); +const viteCliPath = path.join(projectRoot, "node_modules", "vite", "bin", "vite.js"); +const tsupCliPath = path.join(projectRoot, "node_modules", "tsup", "dist", "cli-default.js"); +const electronCommand = require("electron"); // ADE chat shells export ELECTRON_RUN_AS_NODE=1 so the `ade` shim can run // cli.cjs through the bundled Electron binary as Node. If that leaks into @@ -104,35 +111,7 @@ async function waitForFile(filePath, timeoutMs) { // eslint-disable-next-line no-await-in-loop await sleep(150); } -} - -async function waitForStableFile(filePath, timeoutMs, stableWindowMs = 300) { - const startedAt = Date.now(); - let lastSignature = ""; - let stableSince = 0; - - while (true) { - try { - const stat = fs.statSync(filePath); - const signature = `${stat.size}:${stat.mtimeMs}`; - if (signature !== lastSignature) { - lastSignature = signature; - stableSince = Date.now(); - } else if (Date.now() - stableSince >= stableWindowMs) { - return stat; - } - } catch { - lastSignature = ""; - stableSince = 0; - } - - if (Date.now() - startedAt > timeoutMs) { - throw new Error(`Timed out waiting for stable file: ${filePath}`); - } - - // eslint-disable-next-line no-await-in-loop - await sleep(150); - } + return fs.statSync(filePath); } function quoteWindowsCmdArg(value) { @@ -260,11 +239,13 @@ async function main() { let shuttingDown = false; let electron = null; let electronRestartPending = false; + fs.rmSync(mainReadyMarker, { force: true }); const teardown = (signal = "SIGTERM") => { if (shuttingDown) return; shuttingDown = true; - fs.unwatchFile(distMainFile); + fs.unwatchFile(mainReadyMarker); + fs.rmSync(mainReadyMarker, { force: true }); for (const child of children) { terminateChild(child, signal); } @@ -274,10 +255,15 @@ async function main() { process.on("SIGTERM", () => teardown("SIGTERM")); process.on("exit", () => teardown("SIGTERM")); - const viteArgs = ["vite", "--port", String(devPort), "--strictPort"]; + const viteArgs = [viteCliPath, "--port", String(devPort), "--strictPort"]; if (forceViteOptimize) viteArgs.push("--force"); - const vite = spawnProcess("renderer", npxCommand, viteArgs); - const main = spawnProcess("main", npxCommand, ["tsup", "--watch"]); + const vite = spawnProcess("renderer", process.execPath, viteArgs); + const main = spawnProcess( + "main", + process.execPath, + [tsupCliPath, "--watch"], + { ADE_DEV_MAIN_READY_MARKER: mainReadyMarker }, + ); children.add(vite); children.add(main); @@ -293,23 +279,29 @@ async function main() { vite.on("exit", onUnexpectedExit(vite)); main.on("exit", onUnexpectedExit(main)); - const [, initialMainBundleStat] = await Promise.all([ + const [, initialReadyMarkerStat] = await Promise.all([ waitForPort(devPort, 30_000), - waitForStableFile(distMainFile, 30_000), + waitForFile(mainReadyMarker, 30_000), ]); + if (!fs.existsSync(distMainFile)) { + throw new Error(`Main build completed without producing ${distMainFile}`); + } const electronEnv = { VITE_DEV_SERVER_URL: devServerUrl, }; const launchElectron = () => { - const electronArgs = ["electron", `--remote-debugging-port=${remoteDebugPort}`]; + const electronArgs = [`--remote-debugging-port=${remoteDebugPort}`]; + if (process.env.ADE_DISABLE_HARDWARE_ACCEL === "1") { + electronArgs.push("--disable-gpu"); + } // Electron treats the first non-switch argument as the app path. Use the // absolute app root so macOS launches do not fall back to default_app.asar. electronArgs.push(projectRoot); if (process.platform === "darwin") { electronArgs.push("-ApplePersistenceIgnoreState", "YES"); } - const child = spawnProcess("electron", npxCommand, electronArgs, electronEnv); + const child = spawnProcess("electron", electronCommand, electronArgs, electronEnv); electron = child; children.add(child); child.on("exit", (code, signal) => { @@ -319,19 +311,8 @@ async function main() { electron = null; if (electronRestartPending) { electronRestartPending = false; - waitForStableFile(distMainFile, 30_000) - .then((stat) => { - lastMainBundleMtimeMs = stat.mtimeMs; - process.stdout.write("[ade] electron restarted with updated main bundle\n"); - launchElectron(); - }) - .catch((error) => { - process.stderr.write( - `[ade] failed to restart electron after main bundle update: ${error instanceof Error ? error.message : String(error)}\n` - ); - teardown("SIGTERM"); - process.exit(1); - }); + process.stdout.write("[ade] electron restarted after successful main build\n"); + launchElectron(); return; } process.stdout.write( @@ -350,16 +331,16 @@ async function main() { terminateChild(electron, "SIGTERM"); }; - launchElectron(); - - let lastMainBundleMtimeMs = initialMainBundleStat.mtimeMs; - fs.watchFile(distMainFile, { interval: 250 }, (curr) => { + let lastReadyMarkerMtimeMs = initialReadyMarkerStat.mtimeMs; + fs.watchFile(mainReadyMarker, { interval: 250 }, (curr) => { if (shuttingDown) return; if (!curr || curr.nlink === 0) return; - if (!curr || curr.mtimeMs <= lastMainBundleMtimeMs) return; - lastMainBundleMtimeMs = curr.mtimeMs; - requestElectronRestart("main bundle updated"); + if (curr.mtimeMs <= lastReadyMarkerMtimeMs) return; + lastReadyMarkerMtimeMs = curr.mtimeMs; + requestElectronRestart("main build completed"); }); + + launchElectron(); } main().catch((error) => { 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/main.ts b/apps/desktop/src/main/main.ts index 750a4b525..61540149a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -50,6 +50,10 @@ import { captureAgentTurnSettledAnalytics } from "./services/analytics/agentTurn import { initPerfRunFromEnv } from "./services/perf/perfLog"; import { startMetricsSampler } from "./services/perf/metricsSampler"; import { registerPerfIpcHandlers } from "./services/perf/perfIpc"; +import { + ADE_WINDOWS_APP_USER_MODEL_ID, + windowChromeOptions, +} from "./windowAppearance"; import { openKvDb } from "./services/state/kvDb"; import { createRegisteredSyncPeerGate } from "./services/state/syncPeerCompactionGate"; import { ensureAdeDirs } from "./services/state/projectState"; @@ -188,6 +192,7 @@ import { type JsonRpcTransport, } from "../../../ade-cli/src/jsonrpc"; import { resolveMachineAdeLayout } from "../../../ade-cli/src/services/projects/machineLayout"; +import { localIpcListenOptions } from "../../../ade-cli/src/services/runtime/localIpcListenOptions"; import { normalizeProjectRootPath } from "../../../ade-cli/src/services/projects/projectRoots"; import { getSignedInAccountAccessToken } from "../../../ade-cli/src/services/account/accountAuthService"; import { createPushRelayClient } from "../../../ade-cli/src/services/push/pushRelayClient"; @@ -259,6 +264,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"; @@ -269,6 +275,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; @@ -309,6 +340,7 @@ function applyPackagedChannelDefaults(): void { } applyPackagedChannelDefaults(); +const packagedReleaseRepository = readBundledAdeReleaseRepository(); function configureDesktopUserDataPath(): void { const appDataPath = (() => { @@ -618,9 +650,7 @@ async function createWindow(args: { const win = new BrowserWindow({ ...defaultWindowBounds, icon, - // Hide the native title bar but keep macOS traffic lights. - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 12, y: 12 }, + ...windowChromeOptions(process.platform), // Match renderer dark theme to avoid a flash on load. backgroundColor: "#0F0D14", webPreferences: { @@ -907,6 +937,10 @@ protocol.registerSchemesAsPrivileged([ const deeplinkChannel = normalizeAdePackageChannel(process.env.ADE_PACKAGE_CHANNEL); const deeplinkClaimAsDefault = app.isPackaged && deeplinkChannel === null; +if (process.platform === "win32") { + app.setAppUserModelId(ADE_WINDOWS_APP_USER_MODEL_ID); +} + const pendingAppNavigationRequests: AppNavigationRequest[] = []; let dispatchAppNavigationRequest: ((request: AppNavigationRequest) => void) | null = null; let dispatchAppNavigationForProjectRoot: @@ -2230,6 +2264,7 @@ app.whenReady().then(async () => { rollbackQuitAndInstall: rollbackAutoUpdateInstall, getRuntimeActivitySummary: () => localRuntimePool.activitySummary(), productAnalyticsService, + releaseRepository: packagedReleaseRepository, forceQuit: () => { for (const win of BrowserWindow.getAllWindows()) { try { @@ -4277,10 +4312,11 @@ app.whenReady().then(async () => { ? envSocketOverride : `${envSocketOverride}.${Buffer.from(normalizeProjectRoot(projectRoot)).toString("base64url").slice(0, 8)}` : adePaths.socketPath; + const activeRpcSocketPath = rpcSocketPath; - if (!isAdeRuntimeNamedPipePath(rpcSocketPath)) { + if (!isAdeRuntimeNamedPipePath(activeRpcSocketPath)) { try { - fs.unlinkSync(rpcSocketPath); + fs.unlinkSync(activeRpcSocketPath); } catch {} } @@ -4347,11 +4383,11 @@ app.whenReady().then(async () => { }; server.once("listening", handleListening); server.once("error", handleError); - server.listen(rpcSocketPath); + server.listen(localIpcListenOptions(activeRpcSocketPath)); }), ); logger.warn("rpc.socket_server_started", { - socketPath: rpcSocketPath, + socketPath: activeRpcSocketPath, mode: "legacy_desktop", }); } else { @@ -7023,6 +7059,7 @@ app.whenReady().then(async () => { closeCurrentProject, closeProjectByPath, globalStatePath, + releaseRepository: packagedReleaseRepository, builtInBrowserService, productAnalyticsService, publishAttentionNotchSnapshot: (snapshot: AttentionSnapshot) => { diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.test.ts b/apps/desktop/src/main/packagedRuntimeSmoke.test.ts index d51ba01c9..82dfd88db 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.test.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.test.ts @@ -3,7 +3,9 @@ import { classifyClaudeStartupFailure, getClaudeNativeBinaryFileName, getClaudeNativeBinaryPackageName, + probeCrsqliteExtension, } from "./packagedRuntimeSmokeShared"; +import path from "node:path"; describe("packagedRuntimeSmoke", () => { it("classifies a missing bundled Claude binary distinctly", () => { @@ -46,4 +48,11 @@ describe("packagedRuntimeSmoke", () => { expect(getClaudeNativeBinaryFileName("win32")).toBe("claude.exe"); expect(getClaudeNativeBinaryFileName("darwin")).toBe("claude"); }); + + it.skipIf(process.platform !== "win32")("loads the packaged Windows CR-SQLite extension and records a CRR change", () => { + const result = probeCrsqliteExtension( + path.resolve(process.cwd(), "vendor", "crsqlite", "win32-x64", "crsqlite.dll"), + ); + expect(result).toEqual({ ok: true, changeRows: 1 }); + }); }); diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.ts b/apps/desktop/src/main/packagedRuntimeSmoke.ts index 8cdaa7e9b..2cd4c1a76 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.ts @@ -1,10 +1,13 @@ import os from "node:os"; +import path from "node:path"; import type { Query } from "@anthropic-ai/claude-agent-sdk"; import { resolveClaudeCodeExecutable } from "./services/ai/claudeCodeExecutable"; import { resolveCodexExecutable } from "./services/ai/codexExecutable"; +import { resolveDroidExecutable } from "./services/ai/droidExecutable"; import { resolveOpenCodeBinary } from "./services/opencode/openCodeBinaryManager"; import { classifyClaudeStartupFailure, + probeCrsqliteExtension, type ClaudeStartupProbeResult, } from "./packagedRuntimeSmokeShared"; @@ -124,10 +127,28 @@ async function probeClaudeStartup(): Promise { async function main(): Promise { const pty = await import("node-pty"); const claude = await import("@anthropic-ai/claude-agent-sdk"); + const cursor = await import("@cursor/sdk"); + const droid = await import("@factory/droid-sdk"); const claudeExecutable = resolveClaudeCodeExecutable(); const codexExecutable = resolveCodexExecutable(); + const droidExecutable = resolveDroidExecutable(); const openCodeExecutable = resolveOpenCodeBinary(); + const cursorNativePackageRoot = path.resolve( + __dirname, + "..", + "..", + "node_modules", + "@cursor", + "sdk-win32-x64", + ); + const cursorNativeRgPath = path.join(cursorNativePackageRoot, "bin", "rg.exe"); + const cursorNativeSandboxPath = path.join(cursorNativePackageRoot, "bin", "cursorsandbox.exe"); const ptyProbe = await probePty(); + const crsqliteProbe = process.platform === "win32" + ? probeCrsqliteExtension( + path.resolve(__dirname, "..", "..", "vendor", "crsqlite", "win32-x64", "crsqlite.dll"), + ) + : null; const claudeStartup = await probeClaudeStartup(); process.stdout.write(JSON.stringify({ @@ -140,10 +161,17 @@ async function main(): Promise { codexExecutable: typeof resolveCodexExecutable, codexExecutablePath: codexExecutable.path, codexExecutableSource: codexExecutable.source, + cursorSdkCreateAgentPlatform: typeof cursor.createAgentPlatform, + cursorNativeRgPath, + cursorNativeSandboxPath, + droidSdkCreateSession: typeof droid.createSession, + droidExecutablePath: droidExecutable.path, + droidExecutableSource: droidExecutable.source, openCodeExecutable: typeof resolveOpenCodeBinary, openCodeExecutablePath: openCodeExecutable.path, openCodeExecutableSource: openCodeExecutable.source, ptyProbe, + crsqliteProbe, })); } diff --git a/apps/desktop/src/main/packagedRuntimeSmokeShared.ts b/apps/desktop/src/main/packagedRuntimeSmokeShared.ts index cac786e24..89db60433 100644 --- a/apps/desktop/src/main/packagedRuntimeSmokeShared.ts +++ b/apps/desktop/src/main/packagedRuntimeSmokeShared.ts @@ -36,6 +36,42 @@ export type ClaudeStartupProbeResult = | { state: "binary-missing"; message: string } | { state: "runtime-failed"; message: string }; +export type CrsqliteProbeResult = { + ok: true; + changeRows: number; +}; + +export function probeCrsqliteExtension(extensionPath: string): CrsqliteProbeResult { + // Keep node:sqlite lazy and opaque to esbuild. With the desktop bundle's + // Node 18 target, a top-level literal require("node:sqlite") is rewritten + // to require("sqlite"), which crashes Electron before the smoke probe runs. + const nodeSqliteSpecifier = ["node", "sqlite"].join(":"); + const { DatabaseSync } = require(nodeSqliteSpecifier) as { + DatabaseSync: new ( + path: string, + options?: { allowExtension?: boolean }, + ) => import("node:sqlite").DatabaseSync; + }; + const db = new DatabaseSync(":memory:", { allowExtension: true }); + try { + db.enableLoadExtension(true); + db.loadExtension(extensionPath); + db.exec("create table ade_packaged_crr_probe (id text primary key not null, value text)"); + db.prepare("select crsql_as_crr(?)").get("ade_packaged_crr_probe"); + db.prepare("insert into ade_packaged_crr_probe (id, value) values (?, ?)").run("probe", "ready"); + const row = db.prepare( + "select count(*) as count from crsql_changes where [table] = ?", + ).get<{ count: number | bigint }>("ade_packaged_crr_probe"); + const changeRows = Number(row?.count ?? 0); + if (changeRows < 1) { + throw new Error("CR-SQLite loaded but did not record the packaged-runtime probe change."); + } + return { ok: true, changeRows }; + } finally { + db.close(); + } +} + export function getClaudeNativeBinaryPackageName( platform: NodeJS.Platform = process.platform, arch: string = process.arch, diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts index 248b72b64..35188885e 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -310,6 +310,7 @@ function readShellPath( env, stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, + windowsHide: true, }, ); const startIdx = raw.indexOf(PATH_MARKER_START); diff --git a/apps/desktop/src/main/services/ai/providerCredentialSources.ts b/apps/desktop/src/main/services/ai/providerCredentialSources.ts index 794613a2a..9da9824ce 100644 --- a/apps/desktop/src/main/services/ai/providerCredentialSources.ts +++ b/apps/desktop/src/main/services/ai/providerCredentialSources.ts @@ -102,6 +102,7 @@ export function runShellCommand( stdio: ["ignore", "pipe", "pipe"], env: process.env, windowsVerbatimArguments: useCmd, + windowsHide: true, }); let stdout = ""; diff --git a/apps/desktop/src/main/services/ai/providerTaskRunner.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.ts index 77f4489ec..9a9837eaf 100644 --- a/apps/desktop/src/main/services/ai/providerTaskRunner.ts +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.ts @@ -161,6 +161,7 @@ async function runCommand(args: { env, stdio: [args.stdinText != null ? "pipe" : "ignore", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); let stdout = ""; diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 4688877fb..8c314086b 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -2074,6 +2074,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record/dev/null 2>&1`], { encoding: "utf8" }); + const result = spawnSync("sh", ["-lc", `command -v ${command} >/dev/null 2>&1`], { + encoding: "utf8", + windowsHide: true, + }); return result.status === 0; } catch { return false; diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts index 5f5579a38..6e66d4751 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts @@ -7,12 +7,102 @@ import { commandLooksLikeDirectElectronLaunch, commandLooksLikePackageScriptLaunch, insertDebugFlagsIntoDirectElectronCommand, + resolveDirectElectronLaunch, + resolvePackageScriptElectronLaunch, rewritePackageScriptElectronLaunch, + shellQuote, } from "./appControlLaunchCommand"; const DEBUG_FLAGS = ["--remote-debugging-port=9222"]; describe("appControlLaunchCommand", () => { + it("resolves direct Windows Electron commands into argv and env without shell interpolation", () => { + const value = "C:\\Program Files\\ADE's $lane %TEMP% & café"; + expect(resolveDirectElectronLaunch( + `ADE_TEST="${value}" npx electron "C:\\Program Files\\My & App café"`, + DEBUG_FLAGS, + { platform: "win32" }, + )).toEqual({ + command: "npx", + args: ["electron", ...DEBUG_FLAGS, "C:\\Program Files\\My & App café"], + env: { ADE_TEST: value }, + commandForDisplay: expect.any(String), + }); + }); + + it("falls back to the configured shell for single-quoted Windows argv", () => { + expect(resolveDirectElectronLaunch( + "electron 'C:\\Program Files\\My App\\main.js'", + DEBUG_FLAGS, + { platform: "win32" }, + )).toBeNull(); + }); + + it("resolves package scripts into a direct local Electron invocation on Windows", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade app control $ % & café-")); + try { + fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({ + scripts: { + dev: "ADE_TEST=\"quoted $value %TEMP% & café\" electron \"app folder\"", + }, + }), "utf8"); + + const resolved = resolvePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32" }, + ); + expect(resolved).toEqual({ + command: path.join(projectRoot, "node_modules", ".bin", "electron.cmd"), + args: [...DEBUG_FLAGS, "app folder"], + cwd: projectRoot, + env: { ADE_TEST: "quoted $value %TEMP% & café" }, + commandForDisplay: expect.any(String), + }); + expect(resolved?.commandForDisplay).not.toContain("PATH="); + expect(resolved?.commandForDisplay).not.toContain("$PATH"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("emits native PowerShell and cmd environment syntax for complex Windows script fallbacks", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade app control $ % & café-")); + try { + fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({ + scripts: { + dev: "electron . && node post-launch.js", + }, + }), "utf8"); + + const powershell = rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32", shell: "powershell" }, + ); + expect(powershell).toContain("Set-Location -LiteralPath '"); + expect(powershell).toContain("$env:PATH = '"); + expect(powershell).toContain("' + $env:PATH;"); + expect(powershell).not.toContain(":$PATH"); + expect(powershell).not.toContain(" && "); + + const cmd = rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32", shell: "cmd" }, + ); + expect(cmd).toContain('cd /d "'); + expect(cmd).toContain('set "PATH='); + expect(cmd).toContain(';%PATH%" &&'); + expect(cmd).not.toContain(":$PATH"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("detects direct Electron launches and injects debug flags after electron", () => { expect(commandLooksLikeDirectElectronLaunch("FOO=bar npx electron .")).toBe(true); @@ -30,8 +120,13 @@ describe("appControlLaunchCommand", () => { }), "utf8"); expect(commandLooksLikePackageScriptLaunch("npm run dev")).toBe(true); - expect(rewritePackageScriptElectronLaunch("npm run dev", DEBUG_FLAGS, projectRoot)) - .toBe(`PATH=${path.join(projectRoot, "node_modules", ".bin")}:$PATH electron --remote-debugging-port=9222 .`); + expect(rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "linux" }, + )) + .toBe(`PATH=${shellQuote(path.join(projectRoot, "node_modules", ".bin"))}:$PATH electron --remote-debugging-port=9222 .`); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } @@ -64,8 +159,13 @@ describe("appControlLaunchCommand", () => { }, }), "utf8"); - expect(rewritePackageScriptElectronLaunch("cd apps/desktop && npm run dev", DEBUG_FLAGS, projectRoot)) - .toBe(`cd apps/desktop && PATH=${path.join(appDir, "node_modules", ".bin")}:$PATH electron --remote-debugging-port=9222 .`); + expect(rewritePackageScriptElectronLaunch( + "cd apps/desktop && npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "linux" }, + )) + .toBe(`cd apps/desktop && PATH=${shellQuote(path.join(appDir, "node_modules", ".bin"))}:$PATH electron --remote-debugging-port=9222 .`); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts index efcd667ab..af146205d 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts @@ -1,5 +1,23 @@ import fs from "node:fs"; import path from "node:path"; +import { commandArrayToLine, parseCommandLine } from "../../../shared/shell"; + +export type AppControlDirectLaunch = { + command: string; + args: string[]; + commandForDisplay: string; + env?: Record; +}; + +export type AppControlPackageLaunch = AppControlDirectLaunch & { + cwd: string; +}; + +type WindowsShell = "powershell" | "cmd"; +type LaunchOptions = { + platform?: NodeJS.Platform; + shell?: WindowsShell; +}; export function shellQuote(value: string): string { if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value; @@ -37,6 +55,120 @@ export function insertDebugFlagsIntoDirectElectronCommand(command: string, debug ); } +function takeLeadingEnv(input: string): { env: Record; rest: string } { + const env: Record = {}; + let rest = input.trim(); + const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s;&|]+))(?:\s+|$)/; + while (rest) { + const match = rest.match(assignment); + if (!match) break; + env[match[1]!] = match[2] ?? match[3] ?? match[4] ?? ""; + rest = rest.slice(match[0].length).trimStart(); + } + return { env, rest }; +} + +export function resolveDirectElectronLaunch( + command: string, + debugFlags: string[], + options: LaunchOptions = {}, +): AppControlDirectLaunch | null { + const platform = options.platform ?? process.platform; + const { env, rest } = takeLeadingEnv(command); + // Windows' CRT argv rules do not recognize PowerShell-style single quotes. + // Accepting them here would silently split paths containing spaces; leave + // those commands to the selected shell, which owns their quoting semantics. + if (platform === "win32" && rest.includes("'")) return null; + let argv: string[]; + try { + argv = parseCommandLine(rest, { platform }); + } catch { + return null; + } + if (argv.some((arg) => arg === "&&" || arg === "||" || arg === ";" || arg === "|")) { + return null; + } + + const usesNpx = argv[0]?.toLowerCase() === "npx" && argv[1]?.toLowerCase() === "electron"; + const directElectron = argv[0]?.toLowerCase() === "electron" || argv[0]?.toLowerCase() === "electron.exe"; + if (!usesNpx && !directElectron) return null; + + const executable = argv[0]!; + const prefixArgs = usesNpx ? [argv[1]!] : []; + const appArgs = argv.slice(usesNpx ? 2 : 1); + const args = [...prefixArgs, ...debugFlags, ...appArgs]; + return { + command: executable, + args, + commandForDisplay: commandArrayToLine([executable, ...args], { platform }), + ...(Object.keys(env).length ? { env } : {}), + }; +} + +function packageScriptMatch(command: string): RegExpMatchArray | null { + return command.trim().match( + /^(?.*?)(?(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s+)*)(?npm|pnpm|yarn|bun)\s+(?:run\s+)?(?