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+)?(?