From d4e205355c3d1035edcff40dd6ac76e4e3809a2e Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:51:42 +0000 Subject: [PATCH 1/7] ci(agent): package and sign Windows Dev and Prod installers --- .agents/skills/develop-maple-agent/SKILL.md | 4 + .github/workflows/agent-desktop-build.yml | 267 ++++++++++ apps/maple-agent/app/build.rs | 14 +- apps/maple-agent/app/src/profile.rs | 1 + apps/maple-agent/docs/desktop-builds.md | 55 ++- apps/maple-agent/justfile | 2 + apps/maple-agent/scripts/build-release.sh | 19 +- apps/maple-agent/scripts/release-info.py | 44 +- .../scripts/test_windows_build_info.py | 280 +++++++++++ .../scripts/test_windows_release.py | 190 ++++++++ .../maple-agent/scripts/windows-build-info.py | 226 +++++++++ .../maple-agent/scripts/windows-installer.nsi | 134 ++++++ apps/maple-agent/scripts/windows-release.py | 454 ++++++++++++++++++ .../maple-agent/scripts/windows-sign-file.ps1 | 33 ++ docs/app-signing-environments.md | 13 +- docs/repository-workflows.md | 17 +- flake.nix | 2 + scripts/ci/agent_change_detection.py | 2 + scripts/ci/agent_master_selection.py | 2 +- scripts/ci/test_agent_change_detection.py | 2 + scripts/ci/test_agent_master_selection.py | 9 + scripts/ci/test_agent_workflows.py | 105 +++- scripts/ci/test_signing_workflows.py | 2 +- 23 files changed, 1830 insertions(+), 47 deletions(-) create mode 100644 apps/maple-agent/scripts/test_windows_build_info.py create mode 100644 apps/maple-agent/scripts/test_windows_release.py create mode 100644 apps/maple-agent/scripts/windows-build-info.py create mode 100644 apps/maple-agent/scripts/windows-installer.nsi create mode 100644 apps/maple-agent/scripts/windows-release.py create mode 100644 apps/maple-agent/scripts/windows-sign-file.ps1 diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index c5fc350ae..387c4be66 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -104,6 +104,10 @@ feed; publication is a separate protected operation. Invitations require the platform-specific signed installed-update rehearsal. Linux uses user-owned tar bundles and a separate v2 wire schema, preserving the shipped macOS v1 feed; one release may update both platforms to the same version. +Windows x64 Dev/Prod installers are built by the same desktop workflow, with +master Authenticode signing through the existing `windows-signing` environment. +Windows uses manual installer downloads; automatic updates and built-in CUA +remain unsupported. Preserve that boundary when changing package metadata. Dev and unpackaged builds have no production updater. Empty-key profiles still disable update traffic. Do not substitute a test key or inherit feed/key overrides from the launch environment. Embedded profiles, bundle IDs and state namespaces diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index dc5300974..e8c72540e 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -286,6 +286,273 @@ jobs: if-no-files-found: error retention-days: 30 + windows-build: + name: Build Windows binary (${{ matrix.variant }}) + needs: changes + if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.agent != 'false') && (github.event_name == 'pull_request' || (github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} + runs-on: windows-2025 + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + variant: ${{ fromJSON(github.event_name == 'pull_request' && '["dev"]' || '["dev","prod"]') }} + env: + MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} + # Ship the CRT with the executable rather than requiring a machine-wide + # redistributable. Native verification rejects non-system DLL imports. + RUSTFLAGS: -D warnings -C target-feature=+crt-static + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Install pinned Windows Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: 1.98.0 + - name: Select Windows MSVC and SDK + uses: ilammy/msvc-dev-cmd@a102174a2b586eec2ea151a69e6fd14404a8ce7c + with: + arch: x64 + - name: Select native Windows Perl for the static crypto build + run: | + native_perl=/c/Strawberry/perl/bin/perl.exe + if [ ! -x "$native_perl" ]; then + echo 'Native Windows Perl is missing from the runner' >&2 + exit 1 + fi + "$native_perl" -MLocale::Maketext::Simple -MIPC::Cmd -e 'die "OpenSSL requires native Windows Perl\n" unless $^O eq "MSWin32"; print "$^V\n"' + native_perl_windows="$(cygpath -m "$native_perl")" + printf 'OPENSSL_SRC_PERL=%s\n' "$native_perl_windows" >> "$GITHUB_ENV" + - name: Pin and smoke-test the selected Windows linker + run: python3 ../../scripts/ci/agent_windows.py + - name: Select the SDK shader compiler for release GPUI + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if (-not $env:WindowsSdkVerBinPath) { throw 'Selected Windows SDK binary path is missing' } + $fxc = Join-Path $env:WindowsSdkVerBinPath 'x64/fxc.exe' + if (-not (Test-Path -LiteralPath $fxc -PathType Leaf)) { throw "Windows SDK shader compiler is missing: $fxc" } + Write-Host "Selected HLSL compiler: $fxc" + "GPUI_FXC_PATH=$fxc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + - name: Exercise Windows metadata parser and native linker retention + run: python3 scripts/test_windows_build_info.py + - name: Fingerprint the actual release environment + id: environment + run: python3 ../../scripts/ci/agent_cache.py --profile release --output "$RUNNER_TEMP/agent-environment.json" + - name: Cache Agent release dependencies + id: rust-cache + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: apps/maple-agent -> target + add-rust-environment-hash-key: "true" + key: maple-agent-release-v2-${{ matrix.variant }}-${{ steps.environment.outputs.compatibility }} + cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} + save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + - name: Check whether Cargo targets were restored + id: restored-target + run: | + if [ -f "$GITHUB_WORKSPACE/apps/maple-agent/target/CACHEDIR.TAG" ]; then + echo 'present=true' >> "$GITHUB_OUTPUT" + else + echo 'present=false' >> "$GITHUB_OUTPUT" + fi + - name: Restore compatible legacy Cargo targets on a cache miss + if: ${{ steps.rust-cache.outputs.cache-hit != 'true' && steps.restored-target.outputs.present != 'true' }} + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + workspaces: apps/maple-agent -> target + add-rust-environment-hash-key: "false" + key: maple-agent-release-v2-${{ matrix.variant }}-${{ steps.environment.outputs.compatibility }} + cmd-format: python3 "${{ github.workspace }}/scripts/ci/agent_cache_command.py" {0} + save-if: "false" + - name: Record the current build environment after restore + run: | + mkdir -p target/ci-metrics + cp "$RUNNER_TEMP/agent-environment.json" target/ci-metrics/environment.json + - name: Verify the canonical SDK and proxy dependency graph + run: | + metadata="$(mktemp)" + trap 'rm -f "$metadata"' EXIT + cargo metadata --locked --format-version 1 > "$metadata" + python3 ../../scripts/ci/verify-agent-rust-deps.py "$metadata" + python3 scripts/verify-sqlcipher-build.py "$metadata" + - name: Install pinned Windows notice generator + run: | + set -euo pipefail + ( + cd "$RUNNER_TEMP" + curl --fail --location --show-error --silent \ + https://github.com/EmbarkStudios/cargo-about/releases/download/0.9.0/cargo-about-0.9.0-x86_64-pc-windows-msvc.tar.gz \ + --output cargo-about-0.9.0.tar.gz + printf '%s %s\n' 87daca52ad79980b42b2de6acdec84e143be41e2d4bbebedba0d0c90c49b055a cargo-about-0.9.0.tar.gz | sha256sum --check - + tar -xzf cargo-about-0.9.0.tar.gz + ) + printf '%s\n' "$RUNNER_TEMP/cargo-about-0.9.0-x86_64-pc-windows-msvc" >> "$GITHUB_PATH" + - name: Build Windows release binary and notices without signing credentials + run: python3 ../../scripts/ci/agent_measure.py --phase windows-release-build --target target -- ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" + - name: Inspect immutable Windows profile and source + run: python3 scripts/windows-release.py prepare "$MAPLE_RELEASE_PROFILE" + - name: Upload prebuilt Windows binary and notices + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: maple-agent-${{ matrix.variant }}-windows-x86_64-prebuilt-${{ github.run_id }} + overwrite: true + path: | + apps/maple-agent/target/release/maple-agent.exe + apps/maple-agent/target/third-party-notices + if-no-files-found: error + retention-days: 7 + - name: Summarize phase timings and target size + if: always() + run: python3 ../../scripts/ci/agent_measure.py --summarize target + - name: Upload bounded release measurements + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: maple-agent-release-metrics-${{ matrix.variant }}-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: apps/maple-agent/target/ci-metrics/ + if-no-files-found: warn + retention-days: 7 + + windows: + name: Signed Windows (${{ matrix.variant }}) + needs: windows-build + if: ${{ always() && !cancelled() && needs.windows-build.result == 'success' && github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + environment: windows-signing + permissions: + contents: read + id-token: write + attestations: write + runs-on: windows-2025 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + variant: [dev, prod] + env: + MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Download exact prebuilt Windows binary and notices + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: maple-agent-${{ matrix.variant }}-windows-x86_64-prebuilt-${{ github.run_id }} + path: apps/maple-agent/target + - name: Inspect immutable Windows profile and source before signing + run: python3 scripts/windows-release.py prepare "$MAPLE_RELEASE_PROFILE" + - name: Azure login for Artifact Signing + uses: azure/login@2dd0bbf4064d5a1812889dc200bb8eed2597f82a + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Install pinned Artifact Signing module + shell: pwsh + run: ../../scripts/ci/install-windows-artifact-signing.ps1 + - name: Sign and package Windows Agent + run: python3 scripts/windows-release.py package "$MAPLE_RELEASE_PROFILE" + env: + MAPLE_WINDOWS_ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + MAPLE_WINDOWS_ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + MAPLE_WINDOWS_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + - name: Attest final Windows packages + uses: actions/attest@281a49d4cbb0a72c9575a50d18f6deb515a11deb + with: + subject-path: apps/maple-agent/dist/${{ matrix.variant }}/* + - name: Upload Agent packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: maple-agent-${{ matrix.variant }}-windows-x86_64-${{ github.run_id }} + overwrite: true + path: apps/maple-agent/dist/${{ matrix.variant }}/ + if-no-files-found: error + retention-days: 30 + + windows-unsigned: + name: Unsigned Windows packaging (${{ matrix.variant }}) + needs: windows-build + if: ${{ always() && !cancelled() && needs.windows-build.result == 'success' && github.event_name == 'pull_request' }} + runs-on: windows-2025 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + variant: [dev] + env: + MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Download exact prebuilt Windows binary and notices + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: maple-agent-${{ matrix.variant }}-windows-x86_64-prebuilt-${{ github.run_id }} + path: apps/maple-agent/target + - name: Package unsigned Windows Agent preview + run: python3 scripts/windows-release.py package "$MAPLE_RELEASE_PROFILE" --unsigned + - name: Upload Agent packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: maple-agent-${{ matrix.variant }}-windows-x86_64-unsigned-${{ github.run_id }} + overwrite: true + path: apps/maple-agent/dist/${{ matrix.variant }}/ + if-no-files-found: error + retention-days: 30 + + verify-windows: + name: Verify downloaded Windows (${{ matrix.variant }}) + needs: [windows, windows-unsigned] + if: ${{ always() && !cancelled() && ((github.event_name == 'pull_request' && needs.windows-unsigned.result == 'success') || (needs.windows.result == 'success' && github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch'))) }} + permissions: + contents: read + attestations: read + runs-on: windows-2025 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + variant: ${{ fromJSON(github.event_name == 'pull_request' && '["dev"]' || '["dev","prod"]') }} + env: + MAPLE_RELEASE_PROFILE: ${{ matrix.variant }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Download exact Windows packages + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: maple-agent-${{ matrix.variant }}-windows-x86_64${{ github.event_name == 'pull_request' && '-unsigned' || '' }}-${{ github.run_id }} + path: artifacts + - name: Verify downloaded package provenance + if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + working-directory: . + run: | + set -euo pipefail + shopt -s nullglob + files=(artifacts/*) + test "${#files[@]}" -gt 0 + for file in "${files[@]}"; do + test -f "$file" && test ! -L "$file" + gh attestation verify "$file" --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/agent-desktop-build.yml" \ + --source-ref refs/heads/master --source-digest "$GITHUB_SHA" \ + --deny-self-hosted-runners + done + env: + GH_TOKEN: ${{ github.token }} + - name: Verify downloaded Windows package and install lifecycle + run: | + args=() + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then args=(--unsigned); fi + python3 scripts/windows-release.py verify "$MAPLE_RELEASE_PROFILE" ../../artifacts "${args[@]}" --smoke + linux: name: Linux bundle (${{ matrix.variant }}) needs: changes diff --git a/apps/maple-agent/app/build.rs b/apps/maple-agent/app/build.rs index b31dc499c..1d917bf76 100644 --- a/apps/maple-agent/app/build.rs +++ b/apps/maple-agent/app/build.rs @@ -13,9 +13,9 @@ fn main() { source_sha, } = git_metadata::emit(); - // The CLI and macOS signing preflight read the same immutable public - // metadata. Signing must inspect the Mach-O section without running a - // downloaded executable while its keychain or Apple credentials exist. + // The CLI and native signing preflights read the same immutable public + // metadata. Signing must inspect the artifact without running a downloaded + // executable while signing credentials exist. let channel = std::env::var("MAPLE_RELEASE_PROFILE").ok(); let pcr = std::env::var("VITE_OPEN_SECRET_PCR_ENVIRONMENT").ok(); let (channel, profile) = release_profile::select_profile(channel.as_deref(), pcr.as_deref()) @@ -69,4 +69,12 @@ fn main() { println!("cargo:rustc-link-arg-bin=maple-agent=-Wl,-rpath,/usr/lib/swift"); println!("cargo:rustc-link-arg-bin=maple-agent=-Wl,-rpath,@executable_path/../Frameworks"); } + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") + && std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() == Ok("x86_64") + { + // #[used] retains the object symbol; /INCLUDE additionally roots it + // through MSVC release /OPT:REF and LTO. x64 C symbols are undecorated. + println!("cargo:rustc-link-arg-bin=maple-agent=/INCLUDE:MAPLE_AGENT_BUILD_INFO"); + } } diff --git a/apps/maple-agent/app/src/profile.rs b/apps/maple-agent/app/src/profile.rs index 128cfe4d9..3a4d0bf84 100644 --- a/apps/maple-agent/app/src/profile.rs +++ b/apps/maple-agent/app/src/profile.rs @@ -21,6 +21,7 @@ fn value_for(profile: &str, baked: &str, configured: Option) -> String { #[used] #[unsafe(no_mangle)] #[cfg_attr(target_os = "macos", unsafe(link_section = "__TEXT,__maple_info"))] +#[cfg_attr(target_os = "windows", unsafe(link_section = ".maple"))] static MAPLE_AGENT_BUILD_INFO: [u8; include_bytes!(concat!(env!("OUT_DIR"), "/build-info.json")) .len()] = *include_bytes!(concat!(env!("OUT_DIR"), "/build-info.json")); diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 1f4d024f7..054bc4944 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -3,7 +3,7 @@ `Maple Agent Desktop Builds` builds both hosted profiles when Agent inputs change on protected `master`, and always on a manual dispatch from `master`. It compares the current source with the last successful ancestor run that actually completed -all four Dev/Prod macOS/Linux downloaded-package verifiers. Failed, in-flight and +all six Dev/Prod macOS/Linux/Windows downloaded-package verifiers. Failed, in-flight and selector-only runs do not advance that baseline, so a later unrelated push still builds outstanding Agent changes. Missing history, API failures or no verified baseline in the last 100 successful master runs select a full build. PRs continue @@ -18,12 +18,14 @@ with a bundled runtime and a normal application directory. CI checks each downloaded bundle's command-line startup and detached host lifecycle in an Ubuntu 24.04 container with no Nix installation and no network access. Each master run completes independently; newer PR updates cancel older -previews. +previews. Windows x64 packages are per-user NSIS installers. Trusted master +packages use the existing Azure publisher to Authenticode-sign the application, +embedded uninstaller and final installer. PRs produce unsigned Dev previews. | Profile | Application | macOS bundle ID | Configuration/data namespace | Update delivery | | --- | --- | --- | --- | --- | | Dev | Maple Work Dev | `cloud.opensecret.maple.agent.dev` | `maple-agent-dev` | Disabled; master Actions artifacts | -| Prod | Maple Work | `cloud.opensecret.maple.agent` | `maple-agent-prod` | Signed Agent feed with primary/backup pins; publication separately gated | +| Prod | Maple Work | `cloud.opensecret.maple.agent` | `maple-agent-prod` | macOS/Linux signed Agent feed; Windows manual installer; publication separately gated | The binary embeds its profile, hosted API/client/billing settings, and PCR environment. `--build-info` prints the public build identity as JSON without @@ -51,6 +53,17 @@ credentials. Runtime metadata, duplicate Swift runtime, and real GPUI window/first-render checks run only in credential-free verification. GUI startup uses empty temporary state and does not sign in. +Windows also compiles without signing credentials, linking the CRT statically. +A fresh packaging runner reads the executable's dedicated read-only `.maple` +PE metadata section and checks profile/source without executing artifact code. +Master packaging reuses `windows-signing` and the existing Azure signing helpers; +it does not need another Azure app registration or certificate profile. Native +verifiers check installed payload identity, system-only DLL imports, Authenticode +signatures for master packages, CLI startup and isolated install/uninstall. These +checks do not establish interactive rendering, browser sign-in or tray behavior. +Built-in Windows computer-use remains tracked separately in +[issue #1295](https://github.com/MaplePrivacyLabs/Maple/issues/1295). + ## Download and install Open the latest successful `Maple Agent Desktop Builds` run with completed @@ -61,17 +74,26 @@ Its artifacts include the profile, platform, and run ID in their names: - `maple-agent-prod-macos-aarch64-RUN` - `maple-agent-dev-linux-x86_64-RUN` - `maple-agent-prod-linux-x86_64-RUN` +- `maple-agent-dev-windows-x86_64-RUN` +- `maple-agent-prod-windows-x86_64-RUN` Artifact names stay stable when failed jobs are retried, so verification can reuse a successful profile's package from an earlier attempt. A rerun producer replaces only its own profile/platform artifact after packaging verification; the replacement has a new artifact ID and download link. Every package must still match the run's source commit and pass the same native verification. -Internal macOS binary artifacts use `macos-aarch64-prebuilt` in their names. +Internal binary artifacts use `macos-aarch64-prebuilt` or +`windows-x86_64-prebuilt` in their names. They retain the same run/profile identity across retries, so a packaging-only retry can reuse a successful compilation while that internal artifact is retained. Final packages are retained for 30 days; internal prebuilt binaries for 7 days. +Windows packages contain an `-windows-x86_64-setup.exe` installer, public +build-info, package manifest and checksums. Run the installer as your desktop +user. Dev and Prod use distinct per-user installation directories, shortcuts and +uninstall entries, separate from Research. Uninstall removes installed program +files and integration while preserving account, settings and task data. Windows +upgrades currently use a new installer download; automatic updates are disabled. macOS packages contain a DMG and application archive; drag the chosen Agent app into Applications. Both Agent profiles can coexist with Maple Research. Linux packages contain @@ -172,6 +194,24 @@ continues to use the macOS feed. Dev has no update feed on either platform. Both platforms retain one release version and source commit; separate feed paths preserve compatibility with existing macOS clients. +Windows builds run on the pinned Windows Rust/MSVC environment in CI, with +`RUSTFLAGS='-D warnings -C target-feature=+crt-static'` set before fingerprinting +and compilation. `build-release.sh` selects the explicit x64 MSVC target so host +build tools keep their normal linkage, then stages the exact resulting executable. +From the component directory in Git Bash on Windows: + +```sh +./scripts/build-release.sh dev +python3 scripts/windows-release.py package dev --unsigned +python3 scripts/windows-release.py verify dev dist/dev --unsigned --smoke +``` + +The package script verifies its pinned NSIS compiler download. Signed packaging +uses the same command without `--unsigned`, only in the trusted master job after +Azure login and the existing pinned Artifact Signing module setup. The signing +runner never executes downloaded app code. Install/uninstall smoke runs only on +a separate credential-free verifier. + For a produced Prod updater archive, compile the trusted data-only scanner and validate the exact package directory (add `--unsigned` for PR packages): @@ -198,13 +238,14 @@ The independent Agent publisher promotes only verified Prod artifacts into the Agent R2 bucket. It creates no GitHub Release/tag and does not redeploy Research. The client accepts only its signed Agent product/channel/target manifest, with the primary and backup keys embedded in the Prod profile. Packaged Prod builds -check their platform's feed. Publication remains separately gated, and +on macOS/Linux check their platform's feed. Windows has no updater feed or +automatic update backend. Publication remains separately gated, and invitations require a signed native update rehearsal on every required launch platform. An empty-key profile still disables updater traffic. See [updater activation](updates.md). -No new app-signing environment or Apple credential migration is needed. Keep the -existing `desktop-signing` credentials and policies, Research updater key, +No new app-signing environment or credential migration is needed. Keep the +existing `desktop-signing` and `windows-signing` credentials and policies, Research updater key, publication workflow and compatibility URLs intact. No Research install is replaced, overwritten or migrated by Agent packaging or publication. diff --git a/apps/maple-agent/justfile b/apps/maple-agent/justfile index a4ac552b8..b2d8cc515 100644 --- a/apps/maple-agent/justfile +++ b/apps/maple-agent/justfile @@ -102,6 +102,8 @@ packaging-test: python3 ./scripts/test_linux_install.py python3 ./scripts/test_macos_release_dylibs.py python3 ./scripts/test_macos_build_info.py + python3 ./scripts/test_windows_build_info.py + python3 ./scripts/test_windows_release.py python3 ./scripts/test_smoke_macos_release.py python3 ./scripts/test_smoke_linux_bundle_host.py diff --git a/apps/maple-agent/scripts/build-release.sh b/apps/maple-agent/scripts/build-release.sh index 10214c1c8..495340a8f 100755 --- a/apps/maple-agent/scripts/build-release.sh +++ b/apps/maple-agent/scripts/build-release.sh @@ -12,6 +12,9 @@ cd "$component" # scripts receive certificates, notarization passwords, or updater keys. unset APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_ID_PASSWORD unset APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD +unset AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID +unset MAPLE_WINDOWS_ARTIFACT_SIGNING_ENDPOINT MAPLE_WINDOWS_ARTIFACT_SIGNING_ACCOUNT_NAME +unset MAPLE_WINDOWS_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME export MAPLE_RELEASE_PROFILE="$1" if [[ "$1" == dev ]]; then export VITE_OPEN_SECRET_PCR_ENVIRONMENT=development @@ -25,7 +28,19 @@ fi # Populate all targets before the offline notice inventory on this unprivileged # worker; signing workers consume the generated output without Cargo. cargo fetch --locked -cargo build --release -p maple-agent-app --locked --timings +target_args=() +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) target_args=(--target x86_64-pc-windows-msvc) ;; +esac +# An explicit Windows target keeps +crt-static off host proc macros and build +# scripts. Stage only this build's executable at the shared prebuilt path. +cargo build --release -p maple-agent-app --locked --timings "${target_args[@]}" +if [[ ${#target_args[@]} != 0 ]]; then + mkdir -p target/release + cp target/x86_64-pc-windows-msvc/release/maple-agent.exe target/release/maple-agent.exe +fi python3 "$component/scripts/generate-third-party-notices.py" -"$component/target/release/maple-agent" --build-info | +binary="$component/target/release/maple-agent" +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) binary="${binary}.exe" ;; esac +"$binary" --build-info | python3 -c 'import json,sys; info=json.load(sys.stdin); expected=sys.argv[1]; assert info["profile"] == expected, "incorrect release profile"' "$1" diff --git a/apps/maple-agent/scripts/release-info.py b/apps/maple-agent/scripts/release-info.py index 69cad1cf4..eafe5a824 100755 --- a/apps/maple-agent/scripts/release-info.py +++ b/apps/maple-agent/scripts/release-info.py @@ -23,7 +23,7 @@ def read_info(profile, path, source_sha=None, platform=None): - return validate_info(profile, json.loads(Path(path).read_text()), source_sha, platform) + return validate_info(profile, json.loads(Path(path).read_text(encoding="utf-8")), source_sha, platform) def validate_update_keys(keys): @@ -51,16 +51,19 @@ def validate_info(profile, info, source_sha=None, platform=None): profiles = json.loads((COMPONENT / "release-profiles.json").read_text()) if profile not in ("dev", "prod"): raise ValueError("release profile must be dev or prod") - if platform not in (None, "macos-aarch64", "linux-x86_64"): + if platform not in (None, "macos-aarch64", "linux-x86_64", "windows-x86_64"): raise ValueError("unsupported artifact platform") if not isinstance(info, dict) or set(info) != BUILD_FIELDS: raise ValueError("build-info must contain only the documented public release fields") if info["profile"] != profile: raise ValueError("binary release profile does not match the requested package") for field in PUBLIC_FIELDS: - expected = profiles[profile]["linux_update_feed_url"] if ( - field == "update_feed_url" and platform == "linux-x86_64" - ) else profiles[profile][field] + if field == "update_feed_url" and platform == "windows-x86_64": + expected = None # Windows distribution has no installed updater yet. + elif field == "update_feed_url" and platform == "linux-x86_64": + expected = profiles[profile]["linux_update_feed_url"] + else: + expected = profiles[profile][field] if info[field] != expected: raise ValueError(f"binary {field} does not match the checked-in release profile") if not isinstance(info["prerelease"], bool): @@ -125,6 +128,10 @@ def linux_package_name(info): return f"maple-agent-{info['profile']}-{info['version']}-{info['git_revision']}-linux-x86_64.tar.gz" +def windows_package_name(info): + return f"maple-agent-{info['profile']}-{info['version']}-{info['git_revision']}-windows-x86_64-setup.exe" + + def public_files(directory): files = list(directory.iterdir()) if any(path.is_symlink() or not path.is_file() for path in files): @@ -134,10 +141,10 @@ def public_files(directory): def write_manifest(info, directory, platform, unsigned): directory = Path(directory) - if platform not in ("macos-aarch64", "linux-x86_64"): + if platform not in ("macos-aarch64", "linux-x86_64", "windows-x86_64"): raise ValueError("unsupported artifact platform") files = public_files(directory) - suffix = ".dmg" if platform == "macos-aarch64" else ".tar.gz" + suffix = {"macos-aarch64": ".dmg", "linux-x86_64": ".tar.gz", "windows-x86_64": "-setup.exe"}[platform] packages = sorted(name for name in files if name.endswith(suffix)) if len(packages) != 1: raise ValueError("artifact directory must contain exactly one platform package") @@ -162,11 +169,17 @@ def write_manifest(info, directory, platform, unsigned): # do not describe a SHA-256 file as a detached cryptographic signature. manifest["macos_signing"] = None manifest["linux_integrity"] = "sha256" - (directory / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + if platform == "windows-x86_64": + expected = {"build-info.json", windows_package_name(info)} + if packages[0] != windows_package_name(info) or not expected <= files or files - expected - {"manifest.json", "SHA256SUMS"}: + raise ValueError("Windows artifact directory must contain only the expected installer and public metadata") + manifest["macos_signing"] = None + manifest["windows_signing"] = "unsigned" if unsigned else "authenticode" + (directory / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n") files = sorted(p for p in directory.iterdir() if p.is_file() and p.name != "SHA256SUMS") (directory / "SHA256SUMS").write_text("".join( f"{hashlib.sha256(p.read_bytes()).hexdigest()} {p.name}\n" for p in files - )) + ), encoding="utf-8", newline="\n") for path in directory.iterdir(): if path.is_file(): path.chmod(0o644) @@ -178,7 +191,7 @@ def verify_artifacts(profile, directory, unsigned, source_sha=None, platform=Non files = public_files(directory) manifest = json.loads((directory / "manifest.json").read_text()) manifest_platform = manifest.get("platform") - if manifest_platform not in ("macos-aarch64", "linux-x86_64"): + if manifest_platform not in ("macos-aarch64", "linux-x86_64", "windows-x86_64"): raise ValueError("unsupported artifact platform") if platform is not None and manifest_platform != platform: raise ValueError("manifest platform does not match the requested platform") @@ -201,6 +214,13 @@ def verify_artifacts(profile, directory, unsigned, source_sha=None, platform=Non "build-info.json", "manifest.json", "SHA256SUMS", linux_package_name(info), }: raise ValueError("Linux artifact directory must contain exactly the expected bundle and public metadata") + if platform == "windows-x86_64": + if manifest.get("macos_signing", "missing") is not None or manifest.get("windows_signing") != ("unsigned" if unsigned else "authenticode"): + raise ValueError("unexpected Windows signing policy") + if manifest.get("package") != windows_package_name(info) or files != { + "build-info.json", "manifest.json", "SHA256SUMS", windows_package_name(info), + }: + raise ValueError("Windows artifact directory must contain exactly the expected installer and public metadata") lines = (directory / "SHA256SUMS").read_text().splitlines() checked = set() for line in lines: @@ -217,7 +237,7 @@ def verify_artifacts(profile, directory, unsigned, source_sha=None, platform=Non if checked != files - {"SHA256SUMS"}: raise ValueError("artifact directory contains unchecked files or directories") package = manifest.get("package", "") - suffix = ".dmg" if platform == "macos-aarch64" else ".tar.gz" + suffix = {"macos-aarch64": ".dmg", "linux-x86_64": ".tar.gz", "windows-x86_64": "-setup.exe"}[platform] if package not in checked or not package.endswith(suffix): raise ValueError("manifest package is missing or has the wrong format") return manifest @@ -304,7 +324,7 @@ def main(): parser.add_argument("--field") parser.add_argument("--build-number", default="1") parser.add_argument("--output") - parser.add_argument("--platform", choices=("macos-aarch64", "linux-x86_64")) + parser.add_argument("--platform", choices=("macos-aarch64", "linux-x86_64", "windows-x86_64")) parser.add_argument("--unsigned", action="store_true") args = parser.parse_args() if args.operation == "verify": diff --git a/apps/maple-agent/scripts/test_windows_build_info.py b/apps/maple-agent/scripts/test_windows_build_info.py new file mode 100644 index 000000000..110c3b20f --- /dev/null +++ b/apps/maple-agent/scripts/test_windows_build_info.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Hermetic PE section, DLL closure and non-executing identity fixtures.""" + +import importlib.util +import json +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + + +SCRIPTS = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("windows_build_info", SCRIPTS / "windows-build-info.py") +metadata = importlib.util.module_from_spec(spec) +spec.loader.exec_module(metadata) +spec = importlib.util.spec_from_file_location("release_info", SCRIPTS / "release-info.py") +release_info = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release_info) +SOURCE_SHA = "a" * 40 +PE_OFFSET = 0x80 +OPTIONAL = PE_OFFSET + 24 +SECTIONS = OPTIONAL + 240 + + +def public_info(profile="dev", source_sha=SOURCE_SHA): + profiles = json.loads((SCRIPTS.parent / "release-profiles.json").read_text()) + public = {field: profiles[profile][field] for field in release_info.PUBLIC_FIELDS} + # Windows has no automatic update feed in this preview. + return dict(public, update_feed_url=None, profile=profile, version="0.1.0", + git_revision=source_sha[:8], source_sha=source_sha) + + +def aligned(value, alignment): + return (value + alignment - 1) // alignment * alignment + + +def pe(payload, *, machine=metadata.MACHINE_AMD64, characteristics=0x22, duplicate=False, + imports=(), delay_imports=()): + """Create a PE32+ image fixture using no native Windows tools.""" + if not isinstance(payload, bytes): + payload = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + sections = [(b".text", b"\xc3", 0x60000020), (b".maple", payload, 0x40000040)] + if duplicate: + sections.append((b".maple", payload, 0x40000040)) + count = len(sections) + bool(imports or delay_imports) + headers_size = aligned(SECTIONS + 40 * count, 512) + headers = bytearray(headers_size) + headers[:2] = b"MZ" + struct.pack_into(" MAX_HEADER_BYTES: + raise ValueError("invalid PE header offset") + header = self.read(position, 24) + machine, count, _, _, _, optional_size, characteristics = struct.unpack_from(" 16 or 112 + directories * 8 > optional_size: + raise ValueError("invalid PE data directory count") + self.directories = [struct.unpack_from(" self.size)) or ( + extent and (address < headers_size or address + extent > image_size) + ) or relocations or lines or reloc_count or line_count: + raise ValueError("invalid PE section bounds or image relocations") + for previous in self.sections: + if raw_size and previous[3] and max(offset, previous[4]) < min(offset + raw_size, previous[4] + previous[3]): + raise ValueError("overlapping PE section file ranges") + previous_extent = max(previous[1], previous[3]) + if extent and previous_extent and max(address, previous[2]) < min(address + extent, previous[2] + previous_extent): + raise ValueError("overlapping PE section virtual ranges") + self.sections.append((name, virtual_size, address, raw_size, offset, flags)) + + def read(self, offset, length): + if offset < 0 or length < 0 or offset + length > self.size: + raise ValueError("truncated PE artifact") + self.binary.seek(offset) + data = self.binary.read(length) + if len(data) != length: + raise ValueError("truncated PE artifact") + return data + + def rva(self, address, length): + for _, virtual_size, start, raw_size, offset, _ in self.sections: + # Import data must exist in the file and mapped virtual contents, + # rather than in raw alignment padding or zero-filled memory. + if start <= address and address + length <= start + min(raw_size, virtual_size): + return self.read(offset + address - start, length) + raise ValueError("PE data RVA is outside a file-backed section") + + def dll_name(self, address): + for _, virtual_size, start, raw_size, offset, _ in self.sections: + available = min(raw_size, virtual_size) - (address - start) + if start <= address and available > 0: + data = self.read(offset + address - start, min(MAX_DLL_BYTES, available)) + name, separator, _ = data.partition(b"\0") + if not separator or not re.fullmatch(rb"[A-Za-z0-9_+.-]+\.(?:dll|drv)", name, re.IGNORECASE) or b".." in name: + raise ValueError("invalid or oversized PE import DLL name") + return name.decode("ascii").lower() + raise ValueError("PE import name RVA is outside a file-backed section") + + def imports(self): + names = set() + for index, record_size, name_offset in ((1, 20, 12), (13, 32, 4)): + if index >= len(self.directories): + continue + address, size = self.directories[index] + if not address and not size: + continue + if not address or not record_size <= size <= MAX_IMPORT_BYTES: + raise ValueError("invalid or oversized PE import directory") + table = self.rva(address, size) + for offset in range(0, size - record_size + 1, record_size): + record = table[offset:offset + record_size] + if not any(record): + break + if index == 13 and struct.unpack_from(" raw_size or raw_size > MAX_METADATA_BYTES + 64 * 1024: + raise ValueError("invalid or oversized PE build-info section") + data = self.read(offset, raw_size) + if any(data[length:]): + raise ValueError("PE build-info alignment padding must be zero") + return json.loads(data[:length].decode("utf-8"), object_pairs_hook=unique_object) + + +def inspect_pe(path): + """Return native architecture, subsystem and all regular/delay DLL imports.""" + with open_artifact(path) as image: + return {"machine": "x86_64", "subsystem": image.subsystem, "imports": image.imports()} + + +def read_build_info(path): + with open_artifact(path) as image: + return image.build_info() + + +def validated_build_info(profile, path, source_sha): + if not isinstance(source_sha, str) or not re.fullmatch(r"[0-9a-f]{40}", source_sha): + raise ValueError("expected source commit must be a full lowercase SHA") + spec = importlib.util.spec_from_file_location("release_info", Path(__file__).with_name("release-info.py")) + release_info = importlib.util.module_from_spec(spec) + spec.loader.exec_module(release_info) + return release_info.validate_info(profile, read_build_info(path), source_sha, platform="windows-x86_64") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary") + parser.add_argument("--profile", choices=("dev", "prod"), required=True) + parser.add_argument("--source-sha", required=True) + args = parser.parse_args() + print(json.dumps(validated_build_info(args.profile, args.binary, args.source_sha), sort_keys=True, separators=(",", ":"))) + + +if __name__ == "__main__": + try: + main() + except (ValueError, KeyError, OSError, UnicodeDecodeError, struct.error) as error: + print(f"static Windows build-info validation failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/apps/maple-agent/scripts/windows-installer.nsi b/apps/maple-agent/scripts/windows-installer.nsi new file mode 100644 index 000000000..045b777c9 --- /dev/null +++ b/apps/maple-agent/scripts/windows-installer.nsi @@ -0,0 +1,134 @@ +; Per-user Agent installer. Product, payload and signing inputs are supplied by +; windows-release.py from checked profile metadata and the exact source tree. +Unicode true +!include "${MAPLE_CONFIG}" + +Name "${DISPLAY_NAME}" +OutFile "${INSTALLER}" +InstallDir "$LOCALAPPDATA\Programs\${DISPLAY_NAME}" +InstallDirRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "InstallLocation" +RequestExecutionLevel user +SetCompressor /SOLID lzma +ShowInstDetails show +ShowUninstDetails show +VIProductVersion "${NUMERIC_VERSION}.0" +VIAddVersionKey /LANG=1033 "ProductName" "${DISPLAY_NAME}" +VIAddVersionKey /LANG=1033 "FileDescription" "${DISPLAY_NAME} Setup" +VIAddVersionKey /LANG=1033 "FileVersion" "${VERSION}" +VIAddVersionKey /LANG=1033 "LegalCopyright" "Maple Privacy Labs" + +!ifdef SIGNER +; NSIS generates a standalone uninstaller and signs it without running any +; downloaded executable. A signer error aborts the package build. +!uninstfinalize 'pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "${SIGNER}" "%1"' = 0 +!endif + +Page directory +Page instfiles +UninstPage uninstConfirm +UninstPage instfiles + +Function .onInit + SetShellVarContext current + ; This SYSTEM registry key is not redirected by WOW64. Built-in instructions + ; keep the installer payload free of executable third-party UI plugins. + ReadRegStr $0 HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" "PROCESSOR_ARCHITECTURE" + StrCmp $0 "AMD64" 0 unsupported + GetDLLVersion "$SYSDIR\kernel32.dll" $0 $1 + IntOp $0 $0 >> 16 + IntCmp $0 10 supported unsupported supported +unsupported: + MessageBox MB_OK|MB_ICONSTOP "${DISPLAY_NAME} requires Windows 10 or later on an x64 computer." /SD IDOK + SetErrorLevel 1 + Abort +supported: +FunctionEnd + +Section "Install" + ; Fail before changing any installed files if the desktop or background host + ; still has the executable open. Append mode preserves the existing bytes; + ; opening for write fails while Windows maps the running executable. + IfFileExists "$INSTDIR\maple-agent.exe" 0 ready + ClearErrors + FileOpen $0 "$INSTDIR\maple-agent.exe" a + IfErrors locked + FileClose $0 + Goto ready +locked: + MessageBox MB_OK|MB_ICONSTOP "Close ${DISPLAY_NAME} and its background host before installing." /SD IDOK + SetErrorLevel 1 + Abort +ready: + SetOutPath "$INSTDIR" + SetOverwrite on + ClearErrors + File "${PAYLOAD}\maple-agent.exe" + IfErrors failed + File "${PAYLOAD}\LICENSE.txt" + File "${PAYLOAD}\build-info.json" + SetOutPath "$INSTDIR\notices" + File "${PAYLOAD}\notices\THIRD_PARTY_NOTICES.txt" + File "${PAYLOAD}\notices\THIRD_PARTY_NOTICES.html" + File "${PAYLOAD}\notices\manifest.json" + SetOutPath "$INSTDIR\notices\wordlists" + File "${PAYLOAD}\notices\wordlists\eff-large-wordlist.txt" + File "${PAYLOAD}\notices\wordlists\SOURCES.md" + IfErrors failed + SetOutPath "$INSTDIR" + WriteUninstaller "$INSTDIR\uninstall.exe" + IfErrors failed + CreateDirectory "$SMPROGRAMS\${DISPLAY_NAME}" + CreateShortcut "$SMPROGRAMS\${DISPLAY_NAME}\${DISPLAY_NAME}.lnk" "$INSTDIR\maple-agent.exe" + CreateShortcut "$SMPROGRAMS\${DISPLAY_NAME}\Uninstall.lnk" "$INSTDIR\uninstall.exe" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "DisplayName" "${DISPLAY_NAME}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "DisplayVersion" "${VERSION}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "Publisher" "Maple Privacy Labs" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "UninstallString" '$\"$INSTDIR\uninstall.exe$\"' + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "QuietUninstallString" '$\"$INSTDIR\uninstall.exe$\" /S' + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "DisplayIcon" '$\"$INSTDIR\maple-agent.exe$\",0' + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "NoModify" 1 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "NoRepair" 1 + IfErrors failed + SetErrorLevel 0 + Goto done +failed: + MessageBox MB_OK|MB_ICONSTOP "Installation could not replace every application file. Close the application and retry." /SD IDOK + SetErrorLevel 1 + Abort +done: +SectionEnd + +Section "Uninstall" + SetShellVarContext current + ; A copied uninstaller must never remove files from an arbitrary directory. + ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" "InstallLocation" + StrCmp $0 "$INSTDIR" 0 refuse + ClearErrors + Delete "$INSTDIR\maple-agent.exe" + IfErrors refuse + Delete "$INSTDIR\LICENSE.txt" + Delete "$INSTDIR\build-info.json" + Delete "$INSTDIR\notices\THIRD_PARTY_NOTICES.txt" + Delete "$INSTDIR\notices\THIRD_PARTY_NOTICES.html" + Delete "$INSTDIR\notices\manifest.json" + Delete "$INSTDIR\notices\wordlists\eff-large-wordlist.txt" + Delete "$INSTDIR\notices\wordlists\SOURCES.md" + Delete "$INSTDIR\uninstall.exe" + RMDir "$INSTDIR\notices\wordlists" + RMDir "$INSTDIR\notices" + ; Remove empty application directories only; user-created files and all + ; configuration/data under AppData remain untouched. + RMDir "$INSTDIR" + Delete "$SMPROGRAMS\${DISPLAY_NAME}\${DISPLAY_NAME}.lnk" + Delete "$SMPROGRAMS\${DISPLAY_NAME}\Uninstall.lnk" + RMDir "$SMPROGRAMS\${DISPLAY_NAME}" + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${BUNDLE_ID}" + SetErrorLevel 0 + Goto done +refuse: + MessageBox MB_OK|MB_ICONSTOP "Close ${DISPLAY_NAME} and its background host, then uninstall from its installed location." /SD IDOK + SetErrorLevel 1 + Abort +done: +SectionEnd diff --git a/apps/maple-agent/scripts/windows-release.py b/apps/maple-agent/scripts/windows-release.py new file mode 100644 index 000000000..fcdd00acd --- /dev/null +++ b/apps/maple-agent/scripts/windows-release.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 +"""Package and verify native Agent Windows installers, separately from builds. + +The package path treats the prebuilt executable as data, even before signing. +Only an explicit --smoke on a credential-free Windows verifier runs the app. +""" + +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import threading +import time +import urllib.request +import zipfile + + +SCRIPTS = Path(__file__).resolve().parent +COMPONENT = SCRIPTS.parent +TARGET = "x86_64-pc-windows-msvc" +PLATFORM = "windows-x86_64" +NSIS_VERSION = "3.12" +NSIS_URL = "https://downloads.sourceforge.net/project/nsis/NSIS%203/3.12/nsis-3.12.zip" +NSIS_SHA256 = "56581f90db321581c5381193d796fffcf2d24b2f8fed2160a6c6a3baa67f2c4f" +# Windows 10+ OS components only. No compiler-installation, VC redistributable, +# crypto-provider or application DLL may be supplied by the build runner's PATH. +SYSTEM_IMPORTS = frozenset(""" + advapi32.dll avrt.dll bcrypt.dll bcryptprimitives.dll cabinet.dll cfgmgr32.dll + combase.dll comctl32.dll comdlg32.dll credui.dll crypt32.dll cryptbase.dll cryptnet.dll + cryptsp.dll d2d1.dll d3d11.dll d3d12.dll d3dcompiler_47.dll dbghelp.dll + dcomp.dll dhcpcsvc.dll dnsapi.dll dwmapi.dll dwrite.dll dxgi.dll + gdi32.dll glu32.dll hid.dll imm32.dll iphlpapi.dll kernel32.dll kernelbase.dll + mf.dll mfplat.dll mfreadwrite.dll mmdevapi.dll msasn1.dll msimg32.dll + msctf.dll msvcrt.dll mswsock.dll ncrypt.dll netapi32.dll normaliz.dll ntdll.dll ole32.dll oleacc.dll + oleaut32.dll opengl32.dll powrprof.dll propsys.dll psapi.dll rasapi32.dll + rpcrt4.dll secur32.dll setupapi.dll shcore.dll shell32.dll shlwapi.dll + synchronization.dll ucrtbase.dll uiautomationcore.dll urlmon.dll user32.dll userenv.dll usp10.dll + uxtheme.dll version.dll wevtapi.dll windowscodecs.dll winhttp.dll wininet.dll + winmm.dll winspool.drv wintrust.dll winusb.dll wldap32.dll ws2_32.dll wtsapi32.dll +""".split()) +NOTICE_FILES = ( + "THIRD_PARTY_NOTICES.txt", "THIRD_PARTY_NOTICES.html", "manifest.json", + "wordlists/eff-large-wordlist.txt", "wordlists/SOURCES.md", +) +PAYLOAD_FILES = {"maple-agent.exe", "LICENSE.txt", "build-info.json"} | { + f"notices/{name}" for name in NOTICE_FILES +} + + +def module(name): + spec = importlib.util.spec_from_file_location(name.replace("-", "_"), SCRIPTS / f"{name}.py") + loaded = importlib.util.module_from_spec(spec) + spec.loader.exec_module(loaded) + return loaded + + +release_info = module("release-info") +pe = module("windows-build-info") +notices = module("stage-third-party-notices") + + +def run(arguments, **options): + command = arguments if isinstance(arguments, str) else [str(value) for value in arguments] + return subprocess.run(command, check=True, timeout=options.pop("timeout", 120), **options) + + +def source_sha(): + return run(["git", "-C", COMPONENT, "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip() + + +def regular(path): + if path.is_symlink() or not path.is_file(): + raise ValueError("Windows payload requires regular files") + # Windows junction/reparse points must not redirect a trusted package read. + for parent in (path, *path.parents): + if getattr(parent.lstat(), "st_file_attributes", 0) & 0x400: + raise ValueError("Windows payload cannot traverse a reparse point") + + +def validate_imports(binary): + imports = pe.inspect_pe(binary)["imports"] + unknown = [name for name in imports if name not in SYSTEM_IMPORTS and not re.fullmatch(r"(?:api|ext)-ms-win-[a-z0-9-]+\.dll", name)] + if unknown: + raise ValueError("Windows release has unbundled non-OS DLL imports: " + ", ".join(unknown)) + + +def prepare(profile): + binary = COMPONENT / "target/release/maple-agent.exe" + regular(binary) + info = pe.validated_build_info(profile, binary, source_sha()) + validate_imports(binary) + notices.validate(COMPONENT, notices.output_directory(COMPONENT, TARGET), TARGET) + print("Verified exact Windows binary profile, source, DLL closure and notice inputs") + return binary, info + + +def signature(file, unsigned, *, sign=False): + command = ["pwsh", "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", SCRIPTS / "windows-sign-file.ps1", "-File", file] + if not sign: + command.append("-VerifyOnly") + if unsigned: + command.append("-Unsigned") + run(command, timeout=300) + + +def nsis_literal(value): + value = str(value) + if "\r" in value or "\n" in value or "\0" in value: + raise ValueError("Invalid NSIS configuration value") + return value.replace("$", "$$").replace('"', '$\\"') + + +def write_nsis_config(path, info, payload, installer, unsigned): + values = { + "DISPLAY_NAME": info["display_name"], "BUNDLE_ID": info["bundle_id"], + "VERSION": info["version"], "NUMERIC_VERSION": info["version"].split("-", 1)[0], + "PAYLOAD": payload, "INSTALLER": installer, + } + if not unsigned: + signer = SCRIPTS / "windows-sign-file.ps1" + # !uninstfinalize is a compiler command string. Reject cmd.exe expansion + # syntax in the trusted script's filesystem path rather than quoting it + # as though a JSON string or argv list controlled cmd.exe parsing. + if any(char in str(signer) for char in '%&|<>^"\r\n'): + raise ValueError("Unsafe path for the native uninstaller signing command") + values["SIGNER"] = signer + path.write_text("".join(f'!define {key} "{nsis_literal(value)}"\n' for key, value in values.items()), encoding="utf-8", newline="\n") + + +def unpack_nsis(archive, destination): + if hashlib.sha256(archive.read_bytes()).hexdigest() != NSIS_SHA256: + raise ValueError("NSIS archive does not match the reviewed pin") + with zipfile.ZipFile(archive) as contents: + members = contents.infolist() + if len(members) > 4096 or sum(item.file_size for item in members) > 64 * 1024 * 1024: + raise ValueError("NSIS archive exceeds extraction bounds") + for item in members: + name = PurePosixPath(item.filename) + if name.is_absolute() or ".." in name.parts or not name.parts or name.parts[0] != f"nsis-{NSIS_VERSION}" or "\\" in item.filename or ":" in item.filename: + raise ValueError("Unsafe NSIS archive path") + if stat.S_ISLNK(item.external_attr >> 16): + raise ValueError("NSIS archive cannot contain symlinks") + contents.extractall(destination) + compiler = destination / f"nsis-{NSIS_VERSION}/makensis.exe" + regular(compiler) + return compiler + + +def install_nsis(scratch): + archive = scratch / "nsis.zip" + with urllib.request.urlopen(NSIS_URL, timeout=90) as response: + data = response.read(8 * 1024 * 1024 + 1) + if len(data) > 8 * 1024 * 1024: + raise ValueError("NSIS download exceeds the reviewed archive limit") + archive.write_bytes(data) + return unpack_nsis(archive, scratch / "nsis") + + +def seven_zip(): + for candidate in (Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "7-Zip/7z.exe", shutil.which("7z"), shutil.which("7z.exe")): + if candidate and Path(candidate).is_file(): + return str(candidate) + raise ValueError("The native Windows verifier requires 7-Zip") + + +def extract_member(extractor, installer, name, destination, limit): + destination.parent.mkdir(parents=True, exist_ok=True) + process = subprocess.Popen([extractor, "e", "-so", str(installer), name], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + timer = threading.Timer(120, process.kill) + timer.start() + try: + total = 0 + with destination.open("xb") as output: + while block := process.stdout.read(64 * 1024): + total += len(block) + if total > limit: + raise ValueError("Windows installer member exceeds extraction bounds") + output.write(block) + if process.wait(timeout=5) != 0: + raise ValueError("Could not extract the exact Windows installer member") + return total + finally: + timer.cancel() + if process.poll() is None: + process.kill() + process.wait(timeout=5) + process.stdout.close() + + +def extract_installer(installer, destination, unsigned): + extractor = seven_zip() + listing = run([extractor, "l", "-slt", "-ba", installer], capture_output=True, text=True, encoding="utf-8").stdout + if len(listing) > 256 * 1024: + raise ValueError("Windows installer inventory is too large") + files = {} + for record in re.split(r"\r?\n\r?\n", listing.strip()): + fields = dict(line.split(" = ", 1) for line in record.splitlines() if " = " in line) + if "Path" not in fields or fields.get("Folder") == "+": + continue + name = fields["Path"].replace("\\", "/") + relative = PurePosixPath(name) + if relative.is_absolute() or ".." in relative.parts or ":" in name or name in files: + raise ValueError("Unsafe or duplicate Windows installer payload path") + files[name] = fields + # Depending on the 7-Zip version, an unsigned NSIS uninstaller is either + # reconstructed as a PE or exposed as bytecode with a .nsis suffix. Signed + # builds must contain the actual signed standalone PE from !uninstfinalize. + extras = set(files) - PAYLOAD_FILES + allowed_uninstallers = {"uninstall.exe", "uninstall.exe.nsis"} if unsigned else {"uninstall.exe"} + if len(extras) != 1 or not extras <= allowed_uninstallers or not PAYLOAD_FILES <= set(files): + raise ValueError("Windows installer has an unexpected payload inventory") + uninstaller = next(iter(extras)) + # 7-Zip cannot always report a reconstructed uninstaller's size before + # extraction. Every member is streamed through a hard bound; only that one + # NSIS-generated member may omit its size in the preliminary inventory. + if any(not item.get("Size", "").isdecimal() for name, item in files.items() if name != uninstaller) or ( + files[uninstaller].get("Size", "") and not files[uninstaller]["Size"].isdecimal() + ): + raise ValueError("Windows installer has an invalid payload size") + declared_size = sum(int(item["Size"]) for item in files.values() if item.get("Size")) + if declared_size > 768 * 1024 * 1024: + raise ValueError("Windows installer exceeds payload extraction bounds") + total = 0 + for name, item in files.items(): + declared = int(item["Size"]) if item.get("Size") else None + limit = min(32 * 1024 * 1024 if name == uninstaller else 768 * 1024 * 1024, 768 * 1024 * 1024 - total) + actual_size = extract_member(extractor, installer, name, destination / name, limit) + if declared is not None and actual_size != declared: + raise ValueError("Windows installer member differs from its declared size") + total += actual_size + actual = {path.relative_to(destination).as_posix() for path in destination.rglob("*") if path.is_file()} + if actual != set(files): + raise ValueError("Extracted Windows installer differs from its inventory") + for name in actual: + regular(destination / name) + return destination / uninstaller + + +def verify_payload(profile, directory, expected_info, unsigned, *, verify_signature=True): + binary = directory / "maple-agent.exe" + info = pe.validated_build_info(profile, binary, source_sha()) + if info != expected_info or json.loads((directory / "build-info.json").read_text(encoding="utf-8")) != info: + raise ValueError("Installer payload identity differs from its verified build") + validate_imports(binary) + notices.validate(COMPONENT, directory / "notices", TARGET) + for name, original in (("LICENSE.txt", COMPONENT / "LICENSE"), ("notices/wordlists/eff-large-wordlist.txt", COMPONENT / "crates/maple-agent/src/store/keys/eff-large-wordlist.txt"), ("notices/wordlists/SOURCES.md", COMPONENT / "crates/maple-agent/src/store/keys/SOURCES.md")): + if (directory / name).read_bytes() != original.read_bytes(): + raise ValueError("Installer license or wordlist differs from the reviewed source") + if verify_signature: + signature(binary, unsigned) + + +def package(profile, unsigned): + binary, info = prepare(profile) + destination = COMPONENT / "dist" / profile + if destination.exists(): + raise ValueError("Move the previous profile artifact directory before packaging") + destination.parent.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f".windows-{profile}-", dir=destination.parent) as temporary: + scratch = Path(temporary).resolve() + payload = scratch / "payload" + output = scratch / "output" + payload.mkdir() + output.mkdir() + shutil.copyfile(binary, payload / "maple-agent.exe") + shutil.copyfile(COMPONENT / "LICENSE", payload / "LICENSE.txt") + metadata = json.dumps(info, sort_keys=True, separators=(",", ":")) + "\n" + (payload / "build-info.json").write_text(metadata, encoding="utf-8", newline="\n") + notices.stage(COMPONENT, payload / "notices", TARGET) + signature(payload / "maple-agent.exe", unsigned, sign=not unsigned) + verify_payload(profile, payload, info, unsigned) + installer = output / release_info.windows_package_name(info) + configuration = scratch / "package.nsh" + write_nsis_config(configuration, info, payload, installer, unsigned) + compiler = install_nsis(scratch) + run([compiler, "/V3", f"/DMAPLE_CONFIG={configuration}", SCRIPTS / "windows-installer.nsi"], timeout=300) + signature(installer, unsigned, sign=not unsigned) + shutil.copyfile(payload / "build-info.json", output / "build-info.json") + release_info.write_manifest(info, output, PLATFORM, unsigned) + # Data-only parsing of the compressed final signed bytes; never execute + # the app, generated uninstaller or installer on the signing worker. + verify(profile, output, unsigned, smoke=False) + output.rename(destination) + print(destination) + + +def smoke_environment(scratch): + # Keep Windows process/session basics, excluding CI, Azure, provider and + # account credentials. Use OS-only PATH to catch developer-runtime leaks. + allowed = {"SYSTEMROOT", "WINDIR", "COMSPEC", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "USERNAME", "USERDOMAIN", "PROGRAMDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "COMMONPROGRAMFILES", "COMMONPROGRAMFILES(X86)", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE"} + environment = {key: value for key, value in os.environ.items() if key.upper() in allowed} + windows = Path(os.environ["SystemRoot"]) + environment["PATH"] = os.pathsep.join(str(windows / part) for part in ("System32", "System32/WindowsPowerShell/v1.0")) + for name in ("config", "data", "cache", "tmp"): + (scratch / name).mkdir() + environment.update(XDG_CONFIG_HOME=str(scratch / "config"), XDG_DATA_HOME=str(scratch / "data"), XDG_CACHE_HOME=str(scratch / "cache"), TEMP=str(scratch / "tmp"), TMP=str(scratch / "tmp"), MAPLE_DISABLE_UPDATE_CHECK="1", RUST_LOG="warn,maple_agent=debug,gpui_windows=info") + return environment + + +def smoke_installer(installer, info, unsigned): + import winreg # Only the explicit native smoke path loads the Windows API. + + registry = "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\" + info["bundle_id"] + registry_access = winreg.KEY_READ | winreg.KEY_WOW64_32KEY + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, registry, 0, registry_access): + raise ValueError("Refusing to smoke over an existing Agent installation") + except FileNotFoundError: + pass + with tempfile.TemporaryDirectory(prefix="maple-windows-install-") as temporary: + scratch = Path(temporary).resolve() + environment = smoke_environment(scratch) + installed = scratch / "install with spaces" + command = f'"{installer}" /S /D={installed}' + run(command_as_windows_args(command), env=environment, timeout=120) + executable = installed / "maple-agent.exe" + uninstaller = installed / "uninstall.exe" + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, registry, 0, registry_access) as key: + if winreg.QueryValueEx(key, "InstallLocation")[0] != str(installed): + raise ValueError("Installer did not register the exact per-user installation") + verify_payload(info["profile"], installed, info, unsigned) + signature(uninstaller, unsigned) + actual = run([executable, "--build-info"], env=environment, capture_output=True, text=True).stdout + if json.loads(actual) != info: + raise ValueError("Installed Windows binary reports another profile/source") + run([executable, "--version"], env=environment, stdout=subprocess.DEVNULL) + for _ in range(2): + result = subprocess.run([str(executable), "acp"], cwd=scratch, env=environment, stdin=subprocess.DEVNULL, capture_output=True, timeout=45) + if result.returncode != 1 or not any(message in result.stderr for message in ( + b"Maple needs local store setup. Open Maple to continue.", + b"Maple ACP requires sign-in.", + )): + bounded_diagnostics(result.stderr) + raise ValueError("Installed Windows ACP did not preserve the signed-out guard") + gui_smoke(executable, scratch, environment) + finally: + if executable.is_file(): + run([executable, "host", "stop"], env=environment, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=45) + if uninstaller.is_file(): + sentinel = installed / "user-created-file.txt" + sentinel.write_text("preserve me", encoding="utf-8") + state = scratch / "data" / info["data_namespace"] / "state-marker.txt" + state.parent.mkdir(parents=True, exist_ok=True) + state.write_text("preserve state", encoding="utf-8") + # _?= suppresses the uninstaller's detached temp copy, so the + # process exit really completes the cleanup being checked. + run(command_as_windows_args(f'"{uninstaller}" /S _?={installed}'), env=environment, timeout=120) + if executable.exists() or sentinel.read_text(encoding="utf-8") != "preserve me" or state.read_text(encoding="utf-8") != "preserve state": + raise ValueError("Uninstall did not remove the app while preserving user files and state") + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, registry, 0, registry_access): + raise ValueError("Uninstall left the profile's registration behind") + except FileNotFoundError: + pass + print("Verified Windows install, identity, signed-out ACP, GUI first render, host stop and state-preserving uninstall") + + +def command_as_windows_args(command): + # subprocess accepts a raw CreateProcess command line on Windows. NSIS + # consumes the unquoted trailing /D= or _?= path (which can contain spaces). + # Do not round-trip that suffix through subprocess.list2cmdline. + return command + + +def bounded_diagnostics(data): + # Only this probe's empty private profile is logged. Remove terminal control + # bytes and cap output; never dump parent environment or stored account data. + data = re.sub(rb"\x1b\[[0-9;]*[A-Za-z]", b"", data[-8192:]) + safe = bytes(value for value in data if value in (9, 10, 13) or 32 <= value < 127) + print("Isolated Windows startup diagnostics:\n" + safe.decode("ascii"), file=sys.stderr) + + +def gui_smoke(executable, scratch, environment): + output = scratch / "gui.log" + with output.open("wb") as stream: + process = subprocess.Popen([str(executable)], cwd=scratch, env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=stream) + try: + deadline = time.monotonic() + 45 + ready_at = None + while time.monotonic() < deadline: + if process.poll() is not None: + raise ValueError("Installed Windows GUI exited before first render") + if output.stat().st_size > 2 * 1024 * 1024: + raise ValueError("Installed Windows GUI exceeded diagnostic bounds") + diagnostics = output.read_bytes() + if b"startup: window open at " in diagnostics and b"startup: first render at " in diagnostics: + ready_at = ready_at or time.monotonic() + if time.monotonic() - ready_at >= 1: + return + time.sleep(0.05) + raise ValueError("Installed Windows GUI did not create and render its window") + except (ValueError, OSError, subprocess.SubprocessError): + bounded_diagnostics(output.read_bytes()[-8192:]) + raise + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=10) + + +def verify(profile, directory, unsigned, *, smoke): + directory = directory.resolve() + manifest = release_info.verify_artifacts(profile, directory, unsigned, source_sha(), PLATFORM) + info = release_info.read_info(profile, directory / "build-info.json", source_sha(), PLATFORM) + installer = directory / manifest["package"] + signature(installer, unsigned) + with tempfile.TemporaryDirectory(prefix="maple-windows-extract-") as temporary: + payload = Path(temporary) + uninstaller = extract_installer(installer, payload, unsigned) + verify_payload(profile, payload, info, unsigned) + if not unsigned: + signature(uninstaller, unsigned=False) + if smoke: + if any(name.startswith(("AZURE_", "MAPLE_WINDOWS_ARTIFACT_SIGNING_")) for name in os.environ): + raise ValueError("Installed Windows smoke requires a verifier without signing configuration") + smoke_installer(installer, info, unsigned) + print(f"Verified {profile} Windows installer identity, exact inventory and signatures") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("prepare", "package", "verify")) + parser.add_argument("profile", choices=("dev", "prod")) + parser.add_argument("directory", type=Path, nargs="?") + parser.add_argument("--unsigned", action="store_true") + parser.add_argument("--smoke", action="store_true") + args = parser.parse_args() + if sys.platform != "win32": + raise ValueError("Windows installer packaging and verification require native Windows") + if args.operation == "prepare": + prepare(args.profile) + elif args.operation == "package": + package(args.profile, args.unsigned) + elif args.directory is None: + parser.error("verify requires an artifact directory") + else: + verify(args.profile, args.directory, args.unsigned, smoke=args.smoke) + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.SubprocessError, zipfile.BadZipFile) as error: + print(f"Windows package verification failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/apps/maple-agent/scripts/windows-sign-file.ps1 b/apps/maple-agent/scripts/windows-sign-file.ps1 new file mode 100644 index 000000000..8217106b8 --- /dev/null +++ b/apps/maple-agent/scripts/windows-sign-file.ps1 @@ -0,0 +1,33 @@ +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$File, + [switch]$VerifyOnly, + [switch]$Unsigned +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version 3.0 +if (-not (Test-Path -LiteralPath $File -PathType Leaf)) { + throw "Windows artifact to verify is missing." +} +if (-not $VerifyOnly) { + if ($Unsigned) { throw "Unsigned artifacts cannot enter the signing path." } + $signer = Join-Path $PSScriptRoot "../../../scripts/ci/windows-artifact-sign.ps1" + & $signer -File $File +} +$signature = Get-AuthenticodeSignature -LiteralPath $File +if ($Unsigned) { + if ($signature.Status -ne "NotSigned") { throw "Expected an unsigned PR artifact." } +} else { + $expectedCn = $env:MAPLE_WINDOWS_AUTHENTICODE_EXPECTED_CN + if ([string]::IsNullOrWhiteSpace($expectedCn)) { $expectedCn = "Mutiny Wallet Inc. dba OpenSecret" } + if ($signature.Status -ne "Valid" -or $null -eq $signature.SignerCertificate) { + throw "Windows artifact has no valid Authenticode signature." + } + $pattern = "(^|,\s*)CN=$([regex]::Escape($expectedCn))(\s*,|$)" + if (-not [regex]::IsMatch($signature.SignerCertificate.Subject, $pattern)) { + throw "Windows artifact signer differs from the expected Maple publisher." + } + if ($null -eq $signature.TimeStamperCertificate) { throw "Windows artifact has no timestamp signature." } +} +Write-Host ("verified-agent-windows-signature {0}" -f (Split-Path -Leaf $File)) diff --git a/docs/app-signing-environments.md b/docs/app-signing-environments.md index b8e5c6ea7..66f8669ea 100644 --- a/docs/app-signing-environments.md +++ b/docs/app-signing-environments.md @@ -9,7 +9,7 @@ Branch protection on `master` alone does not protect repository-level secrets. | `desktop-signing` | `TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_ID_PASSWORD`, `APPLE_TEAM_ID`, `KEYCHAIN_PASSWORD` | Research macOS/Linux master and release builds; Agent macOS Dev/Prod packages from trusted master | | `apple-signing` | `APPLE_API_ISSUER`, `APPLE_API_KEY`, `APPLE_API_PRIVATE_KEY`, `APPLE_TEAM_ID` | Shared production iOS and Maple Dev builds and TestFlight submission | | `android-signing` | `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Android master and release builds | -| `windows-signing` | `AZURE_*` signing configuration and both `TAURI_SIGNING_PRIVATE_KEY*` secrets | Windows master and release builds | +| `windows-signing` | `AZURE_*` signing configuration and both `TAURI_SIGNING_PRIVATE_KEY*` secrets | Research Windows master/release builds; Agent Windows Dev/Prod packages from trusted master | | `zapstore-publishing` | `ZAPSTORE_SIGN_WITH` | Existing best-effort publisher after a successful release | Configure each environment with **selected branches and tags**: branch `master` @@ -46,6 +46,17 @@ the step receiving Apple credentials. App launches and Swift runtime checks run only on verifiers without Apple or updater signing secrets. Agent builds upload separately named Actions artifacts. +Agent Windows Dev/Prod packaging likewise reuses `windows-signing`, its existing +Azure login identity, Artifact Signing account/profile and generic signing +helpers. Preserve the environment name and current Azure configuration. The +signer reads the downloaded executable's bounded PE metadata section and validates +its profile/source before Azure login; it never runs downloaded app code or +restores compilation caches. It signs the app, embedded NSIS uninstaller and +finished installer, then attests the final files. Native installation and CLI +smoke checks run on a fresh verifier without signing credentials. Agent Windows +does not consume Research's Tauri updater private key. Windows update publication +is outside this package workflow. + The macOS signing job has `contents: read`, `id-token: write`, and `attestations: write` to mint GitHub build provenance after packaging. Linux compilation keeps a read-only token; a separate trusted `master` job has those diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 338230424..1e14510b1 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -38,11 +38,11 @@ The backend has its own root `opensecret-ci.yml` workflow and change selector; `sdk-integration.yml` tests both SDKs against `services/opensecret/` from the same checkout. Backend changes do not imply Research or Agent packaging. Agent desktop packages compare master against the last successful ancestor run -with all four Dev/Prod macOS/Linux download verifiers completed; selector-only +with all six Dev/Prod macOS/Linux/Windows download verifiers completed; selector-only and failed runs do not consume outstanding changes. Unrelated master pushes skip Agent packages. Manual master dispatch always builds both profiles. PR previews -build and package only Dev on macOS and Linux, including the matching Linux -downloaded-package verifier; Prod packages remain covered by master builds. +build and package only Dev on macOS, Linux and Windows, including the matching +Linux and Windows downloaded-package verifiers; Prod packages remain covered by master builds. PRs keep their merge-base selection and signing stays confined to trusted master. Research desktop, Android and iOS PR workflows group runs by workflow and PR. A new run cancels earlier runs for that same unsigned preview; unrelated PRs @@ -197,6 +197,17 @@ independent; there is no separate ONNX cache warmer. ## Windows runner pilot +Agent Windows x64 release binaries use standard GitHub `windows-2025`, pinned +Rust/MSVC selection, static CRT linkage and the selected SDK's shader compiler. +Credential-free builders upload the executable and third-party notices. A fresh +master job inspects embedded PE profile/source metadata without executing the +artifact, then reuses `windows-signing` and the existing Azure Artifact Signing +helpers for the app, NSIS uninstaller and installer. Separate native verifiers +check downloaded provenance, payload identity, signatures and isolated +install/uninstall. PRs build unsigned Dev installers without signing credentials. +These packages are Actions downloads; Windows automatic updates and built-in +computer-use remain unsupported. + The Research unsigned Windows PR package uses `blacksmith-8vcpu-windows-2025` (Windows Server 2025, public beta). The job records its actual capacity and Visual Studio Build Tools installation before diff --git a/flake.nix b/flake.nix index 759a404cd..72c4954fd 100644 --- a/flake.nix +++ b/flake.nix @@ -829,6 +829,8 @@ python3 apps/maple-agent/scripts/test_linux_install.py python3 apps/maple-agent/scripts/test_macos_release_dylibs.py python3 apps/maple-agent/scripts/test_macos_build_info.py + python3 apps/maple-agent/scripts/test_windows_build_info.py + python3 apps/maple-agent/scripts/test_windows_release.py python3 apps/maple-agent/scripts/test_smoke_macos_release.py python3 apps/maple-agent/scripts/test_smoke_linux_bundle_host.py touch "$out" diff --git a/scripts/ci/agent_change_detection.py b/scripts/ci/agent_change_detection.py index 3908b54af..74a0d83cb 100644 --- a/scripts/ci/agent_change_detection.py +++ b/scripts/ci/agent_change_detection.py @@ -37,6 +37,8 @@ "scripts/ci/verify-agent-rust-deps.py", "scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py", + "scripts/ci/install-windows-artifact-signing.ps1", + "scripts/ci/windows-artifact-sign.ps1", } ) KNOWN_INDEPENDENT_PREFIXES = ( diff --git a/scripts/ci/agent_master_selection.py b/scripts/ci/agent_master_selection.py index b4933d00e..4b15f7fe7 100644 --- a/scripts/ci/agent_master_selection.py +++ b/scripts/ci/agent_master_selection.py @@ -19,7 +19,7 @@ WORKFLOW = ".github/workflows/agent-desktop-build.yml" VERIFIERS = frozenset( f"Verify downloaded {platform} ({profile})" - for platform in ("macOS", "Linux") for profile in ("dev", "prod") + for platform in ("macOS", "Linux", "Windows") for profile in ("dev", "prod") ) diff --git a/scripts/ci/test_agent_change_detection.py b/scripts/ci/test_agent_change_detection.py index b41d70504..94a4b049b 100644 --- a/scripts/ci/test_agent_change_detection.py +++ b/scripts/ci/test_agent_change_detection.py @@ -90,6 +90,8 @@ def test_selector_and_shared_tooling_changes_select_agent(self): "scripts/ci/agent_cache.py", "scripts/ci/agent_measure.py", "scripts/ci/agent_cache_command.py", "scripts/ci/agent_windows.py", + "scripts/ci/windows-artifact-sign.ps1", + "scripts/ci/install-windows-artifact-signing.ps1", "scripts/ci/verify-agent-rust-deps.py", "scripts/ci/apple-toolchain.json", "scripts/ci/select-xcode.py", ): diff --git a/scripts/ci/test_agent_master_selection.py b/scripts/ci/test_agent_master_selection.py index 214f85301..921d3471c 100644 --- a/scripts/ci/test_agent_master_selection.py +++ b/scripts/ci/test_agent_master_selection.py @@ -114,6 +114,15 @@ def test_each_profile_and_platform_must_have_actual_success(self): self.assertTrue(selected) self.assertIn("master run 1", reason) + def test_previous_macos_linux_only_runs_cannot_consume_windows_changes(self): + changed = self.commit("apps/maple-agent/app/src/main.rs", "Windows packaging change\n") + head = self.commit("README.md", "unrelated later push\n") + jobs = verified_jobs() + jobs["jobs"] = [job for job in jobs["jobs"] if "Windows" not in job["name"]] + jobs["total_count"] = len(jobs["jobs"]) + (selected, _), _ = self.select(head, [run_record(2, changed)], {2: jobs}) + self.assertTrue(selected) + def test_partial_latest_attempt_cannot_borrow_older_green_jobs(self): changed = self.commit("apps/maple-agent/app/src/main.rs", "partial retry\n") head = self.commit("README.md", "unrelated\n") diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 1101a7f21..20a76c869 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -232,7 +232,7 @@ def test_parallel_jobs_retain_selection_platforms_and_required_gate(self): def test_dependency_cache_restore_prefix_uses_actual_environment_without_lock_hash(self): for filename, names in (("agent-ci.yml", ("desktop", "linux-release")), - ("agent-desktop-build.yml", ("macos-build", "linux"))): + ("agent-desktop-build.yml", ("macos-build", "linux", "windows-build"))): for name in names: with self.subTest(workflow=filename, job=name): steps = workflow(filename)["jobs"][name]["steps"] @@ -245,11 +245,13 @@ def test_dependency_cache_restore_prefix_uses_actual_environment_without_lock_ha self.assertNotIn("hashFiles", cache["key"]) self.assertEqual(cache["cmd-format"], RUST_CACHE_COMMAND) self.assertEqual(cache["save-if"], "${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}") - self.assertIn("nix develop --no-update-lock-file . -c python3 ../../scripts/ci/agent_cache.py", steps[fingerprint]["run"]) + self.assertIn("python3 ../../scripts/ci/agent_cache.py", steps[fingerprint]["run"]) + if name != "windows-build": + self.assertIn("nix develop --no-update-lock-file . -c", steps[fingerprint]["run"]) def test_legacy_cache_restore_preserves_abi_scope_and_never_writes(self): for filename, names in (("agent-ci.yml", ("desktop", "linux-release")), - ("agent-desktop-build.yml", ("macos-build", "linux"))): + ("agent-desktop-build.yml", ("macos-build", "linux", "windows-build"))): for name in names: with self.subTest(workflow=filename, job=name): steps = workflow(filename)["jobs"][name]["steps"] @@ -277,7 +279,8 @@ def test_primary_prefix_restore_does_not_download_a_second_archive(self): # Exercise the actual workflow's marker script against an empty target # and a restored Cargo target before evaluating that fallback guard. for filename, name in (("agent-ci.yml", "desktop"), ("agent-ci.yml", "linux-release"), - ("agent-desktop-build.yml", "macos-build"), ("agent-desktop-build.yml", "linux")): + ("agent-desktop-build.yml", "macos-build"), ("agent-desktop-build.yml", "linux"), + ("agent-desktop-build.yml", "windows-build")): steps = workflow(filename)["jobs"][name]["steps"] marker = next(step for step in steps if step.get("id") == "restored-target") with tempfile.TemporaryDirectory(prefix="cache restore ") as directory: @@ -372,6 +375,74 @@ class AgentDesktopPackagingBoundaryTests(unittest.TestCase): } ATTEST_ACTION = "actions/attest@281a49d4cbb0a72c9575a50d18f6deb515a11deb" + def test_windows_reuses_existing_environment_only_for_trusted_master_signing(self): + jobs = workflow(self.WORKFLOW)["jobs"] + signer = jobs["windows"] + self.assertEqual(signer["environment"], "windows-signing") + self.assertEqual(signer["runs-on"], "windows-2025") + self.assertEqual(signer["needs"], "windows-build") + self.assertIn(self.MASTER_GUARD, signer["if"]) + self.assertIn("needs.windows-build.result == 'success'", signer["if"]) + steps = signer["steps"] + preflight = next(i for i, step in enumerate(steps) if "windows-release.py prepare" in step.get("run", "")) + login = next(i for i, step in enumerate(steps) if step.get("uses", "").startswith("azure/login@")) + package = next(i for i, step in enumerate(steps) if "windows-release.py package" in step.get("run", "")) + self.assertLess(preflight, login) + self.assertLess(login, package) + self.assertEqual(steps[login]["with"], { + "client-id": "${{ secrets.AZURE_CLIENT_ID }}", + "tenant-id": "${{ secrets.AZURE_TENANT_ID }}", + "subscription-id": "${{ secrets.AZURE_SUBSCRIPTION_ID }}", + }) + self.assertEqual(steps[package]["env"], { + "MAPLE_WINDOWS_ARTIFACT_SIGNING_" + name: "${{ secrets.AZURE_ARTIFACT_SIGNING_" + name + " }}" + for name in ("ENDPOINT", "ACCOUNT_NAME", "CERTIFICATE_PROFILE_NAME") + }) + self.assertNotIn("--unsigned", steps[package]["run"]) + for step in steps: + self.assertNotRegex(step.get("run", ""), r"cargo\b|--smoke|maple-agent\.exe\s+--") + self.assertNotIn("rust-cache@", step.get("uses", "")) + self.assertNotIn("rust-toolchain@", step.get("uses", "")) + self.assertFalse(any("TAURI_SIGNING" in value for value in strings(signer))) + + def test_windows_preview_and_native_verifier_have_no_signing_credentials(self): + jobs = workflow(self.WORKFLOW)["jobs"] + build = jobs["windows-build"] + self.assertIn("target-feature=+crt-static", build["env"]["RUSTFLAGS"]) + steps = build["steps"] + linker = next(i for i, step in enumerate(steps) if "agent_windows.py" in step.get("run", "")) + fingerprint = next(i for i, step in enumerate(steps) if step.get("id") == "environment") + self.assertLess(linker, fingerprint) + self.assertTrue(any("test_windows_build_info.py" in step.get("run", "") for step in steps)) + upload = next(step["with"] for step in steps + if step.get("uses", "").startswith("actions/upload-artifact@")) + self.assertEqual(upload["path"].splitlines(), [ + "apps/maple-agent/target/release/maple-agent.exe", + "apps/maple-agent/target/third-party-notices", + ]) + for name in ("windows-unsigned", "windows"): + download = next(step["with"] for step in jobs[name]["steps"] + if step.get("uses", "").startswith("actions/download-artifact@")) + self.assertEqual(download, {"name": upload["name"], "path": "apps/maple-agent/target"}) + preview = jobs["windows-unsigned"] + self.assertEqual(preview["strategy"]["matrix"], {"variant": ["dev"]}) + self.assertIn("github.event_name == 'pull_request'", preview["if"]) + self.assertNotIn("github.event_name == 'push'", preview["if"]) + self.assertTrue(any('package "$MAPLE_RELEASE_PROFILE" --unsigned' in step.get("run", "") + for step in preview["steps"])) + verify = jobs["verify-windows"] + self.assertEqual(verify["needs"], ["windows", "windows-unsigned"]) + self.assertIn("always() && !cancelled()", verify["if"]) + self.assertIn("needs.windows-unsigned.result == 'success'", verify["if"]) + self.assertIn("needs.windows.result == 'success'", verify["if"]) + smoke = next(step["run"] for step in verify["steps"] if "windows-release.py verify" in step.get("run", "")) + self.assertIn("--smoke", smoke) + self.assertIn("--unsigned", smoke) + self.assertIn('"$GITHUB_EVENT_NAME" = pull_request', smoke) + for name in ("windows-build", "windows-unsigned", "verify-windows"): + self.assertNotIn("environment", jobs[name]) + self.assertFalse(any("secrets." in value for value in strings(jobs[name]))) + def test_only_pr_updates_can_replace_an_in_progress_or_pending_build(self): concurrency = workflow(self.WORKFLOW)["concurrency"] self.assertEqual(concurrency, { @@ -421,12 +492,12 @@ def test_only_trusted_master_can_receive_apple_signing_credentials(self): for name, job in jobs.items(): with self.subTest(job=name): self.assertNotIn("uses", job) - if name in ("macos", "attest-linux"): + if name in ("macos", "attest-linux", "windows"): self.assertEqual(job["permissions"], { "contents": "read", "id-token": "write", "attestations": "write", }) self.assertIn(self.MASTER_GUARD, " ".join(job["if"].split())) - elif name in ("verify-macos", "verify-linux"): + elif name in ("verify-macos", "verify-linux", "verify-windows"): self.assertEqual(job["permissions"], { "contents": "read", "attestations": "read", }) @@ -434,13 +505,13 @@ def test_only_trusted_master_can_receive_apple_signing_credentials(self): self.assertEqual(job["permissions"], {"contents": "read", "actions": "read"}) else: self.assertIn(job.get("permissions"), (None, {"contents": "read"})) - if name != "macos": + if name not in ("macos", "windows"): self.assertNotIn("environment", job) for step in job["steps"]: if "gh attestation verify" in step.get("run", ""): - self.assertIn(name, ("verify-macos", "verify-linux")) + self.assertIn(name, ("verify-macos", "verify-linux", "verify-windows")) self.assertEqual(step["env"], {"GH_TOKEN": "${{ github.token }}"}) - if name == "verify-linux": + if name in ("verify-linux", "verify-windows"): self.assertIn(self.MASTER_GUARD, " ".join(step["if"].split())) elif name == "changes" and step.get("id") == "master": self.assertEqual(step["if"], @@ -515,22 +586,22 @@ def test_master_selection_and_manual_force_fall_back_to_building(self): def test_pr_profiles_match_producers_and_consumers_while_master_keeps_both(self): jobs = workflow(self.WORKFLOW)["jobs"] - for name in ("macos-build", "linux", "verify-linux"): + for name in ("macos-build", "linux", "verify-linux", "windows-build", "verify-windows"): self.assertEqual(set(jobs[name]["strategy"]["matrix"]), {"variant"}) for event, expected in (("pull_request", ["dev"]), ("push", ["dev", "prod"]), ("workflow_dispatch", ["dev", "prod"])): with self.subTest(event=event): - for name in ("macos-build", "linux", "verify-linux"): + for name in ("macos-build", "linux", "verify-linux", "windows-build", "verify-windows"): self.assertEqual(variants_for(jobs[name], event), expected, name) macos_consumer = "macos-unsigned" if event == "pull_request" else "macos" self.assertEqual(variants_for(jobs[macos_consumer], event), expected) for producer, consumer in (("macos-build", macos_consumer), - ("linux", "verify-linux")): + ("linux", "verify-linux"), ("windows-build", "verify-windows")): self.assertEqual(variants_for(jobs[producer], event), variants_for(jobs[consumer], event)) self.assertEqual(jobs["macos-unsigned"]["strategy"]["matrix"], {"variant": ["dev"]}) - for name in ("macos", "attest-linux", "verify-macos"): + for name in ("macos", "attest-linux", "verify-macos", "windows"): self.assertEqual(jobs[name]["strategy"]["matrix"], {"variant": ["dev", "prod"]}) def test_profiles_and_artifacts_keep_platform_variant_and_run_identity(self): @@ -572,7 +643,7 @@ def test_provenance_is_mandatory_and_only_minted_by_trusted_master_jobs(self): attests = [i for i, step in enumerate(job["steps"]) if step.get("uses") == self.ATTEST_ACTION] with self.subTest(job=name): - if name not in ("macos", "attest-linux"): + if name not in ("macos", "attest-linux", "windows"): self.assertEqual(attests, []) self.assertNotIn("id-token", job.get("permissions", {})) self.assertIn(job.get("permissions", {}).get("attestations"), (None, "read")) @@ -582,9 +653,9 @@ def test_provenance_is_mandatory_and_only_minted_by_trusted_master_jobs(self): attest = job["steps"][attests[0]] self.assertNotIn("if", attest) self.assertNotIn("continue-on-error", attest) - if name == "macos": + if name in ("macos", "windows"): package = next(i for i, step in enumerate(job["steps"]) - if "./scripts/package-release.sh" in step.get("run", "")) + if ("./scripts/package-release.sh" if name == "macos" else "windows-release.py package") in step.get("run", "")) upload = next(i for i, step in enumerate(job["steps"]) if step.get("uses", "").startswith("actions/upload-artifact@")) self.assertGreater(attests[0], package) @@ -828,7 +899,7 @@ def test_downloaded_packages_are_verified_without_signing_credentials(self): self.assertNotIn("--appimage-extract", smoke) def test_downloaded_provenance_enforces_workflow_ref_commit_and_every_file(self): - for name in ("verify-macos", "verify-linux"): + for name in ("verify-macos", "verify-linux", "verify-windows"): step = next(step for step in workflow(self.WORKFLOW)["jobs"][name]["steps"] if "gh attestation verify" in step.get("run", "")) with self.subTest(job=name), tempfile.TemporaryDirectory() as directory: diff --git a/scripts/ci/test_signing_workflows.py b/scripts/ci/test_signing_workflows.py index 0d5914163..41f5b325c 100644 --- a/scripts/ci/test_signing_workflows.py +++ b/scripts/ci/test_signing_workflows.py @@ -40,7 +40,7 @@ } SIGNING_SECRETS = set.union(*ENVIRONMENTS.values()) CONSUMERS = { - "agent-desktop-build.yml": {"macos": "desktop-signing"}, + "agent-desktop-build.yml": {"macos": "desktop-signing", "windows": "windows-signing"}, "agent-updates-publish.yml": { "reserve": "updates-production", "sign": "updates-production", "publish": "updates-production", From 7cdd59e7d61a4ae1f02214c57cbb21d158d86963 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:57:24 +0000 Subject: [PATCH 2/7] docs(agent): clarify Windows manual upgrades --- apps/maple-agent/README.md | 3 +++ apps/maple-agent/docs/release-profiles.md | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 9f5a9be96..ce0421f1c 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -608,6 +608,9 @@ check. ## Update and release boundary +Automatic updates currently support macOS and Linux. Windows Dev/Prod installers +are Actions downloads with manual upgrades; see [desktop builds](docs/desktop-builds.md). + Packaged Prod builds use the independent signed Agent update feed. Dev and unpackaged/MDE builds do not check a production updater. The Prod profile pins the update URL and reviewed primary/backup public keys; inherited environment diff --git a/apps/maple-agent/docs/release-profiles.md b/apps/maple-agent/docs/release-profiles.md index 0792de30e..eb232334e 100644 --- a/apps/maple-agent/docs/release-profiles.md +++ b/apps/maple-agent/docs/release-profiles.md @@ -81,6 +81,10 @@ makes no update requests. Active trust requires two distinct public keys; the backup supports planned rotation and does not revoke a compromised accepted primary. +Windows Dev/Prod installers use the same profile and state identities, but their +compiled update feed is absent. Windows upgrades currently require a new +installer download; the existing macOS/Linux feeds do not authorize Windows updates. + Dev and unpackaged builds contain no updater feed or keys. No runtime override can opt either into production updating. `MAPLE_DISABLE_UPDATE_CHECK=1` remains a hard off for every profile. Release tag prefixes still identify packaging From 39ce45c7fd74710da4f1af26fa4604f4f9fb69fd Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:04:40 +0000 Subject: [PATCH 3/7] fix(ci): launch Windows release script through Bash --- .github/workflows/agent-desktop-build.yml | 2 +- scripts/ci/test_agent_workflows.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index e8c72540e..37ba11ab5 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -390,7 +390,7 @@ jobs: ) printf '%s\n' "$RUNNER_TEMP/cargo-about-0.9.0-x86_64-pc-windows-msvc" >> "$GITHUB_PATH" - name: Build Windows release binary and notices without signing credentials - run: python3 ../../scripts/ci/agent_measure.py --phase windows-release-build --target target -- ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" + run: python3 ../../scripts/ci/agent_measure.py --phase windows-release-build --target target -- bash ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" - name: Inspect immutable Windows profile and source run: python3 scripts/windows-release.py prepare "$MAPLE_RELEASE_PROFILE" - name: Upload prebuilt Windows binary and notices diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 20a76c869..b024dad39 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -414,6 +414,10 @@ def test_windows_preview_and_native_verifier_have_no_signing_credentials(self): fingerprint = next(i for i, step in enumerate(steps) if step.get("id") == "environment") self.assertLess(linker, fingerprint) self.assertTrue(any("test_windows_build_info.py" in step.get("run", "") for step in steps)) + release = next(step["run"] for step in steps + if step.get("name") == "Build Windows release binary and notices without signing credentials") + # Native Windows CreateProcess cannot launch a Unix shebang script. + self.assertIn('--target target -- bash ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE"', release) upload = next(step["with"] for step in steps if step.get("uses", "").startswith("actions/upload-artifact@")) self.assertEqual(upload["path"].splitlines(), [ From a38af2482c18252d49826d391863f8e9939db54a Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:16:37 +0000 Subject: [PATCH 4/7] fix(ci): pin the native Git Bash release interpreter --- .github/workflows/agent-desktop-build.yml | 9 ++++++++- scripts/ci/test_agent_workflows.py | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index 37ba11ab5..dbc406e84 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -337,6 +337,10 @@ jobs: "GPUI_FXC_PATH=$fxc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - name: Exercise Windows metadata parser and native linker retention run: python3 scripts/test_windows_build_info.py + - name: Smoke native Git Bash process launch + run: | + git_bash="$(cygpath -w "$BASH")" + python3 -c 'import subprocess, sys; host = subprocess.check_output([sys.argv[1], "-c", "uname -s"], text=True).strip(); assert host.startswith(("MINGW", "MSYS")), host; print("Verified native Git Bash: " + host)' "$git_bash" - name: Fingerprint the actual release environment id: environment run: python3 ../../scripts/ci/agent_cache.py --profile release --output "$RUNNER_TEMP/agent-environment.json" @@ -390,7 +394,10 @@ jobs: ) printf '%s\n' "$RUNNER_TEMP/cargo-about-0.9.0-x86_64-pc-windows-msvc" >> "$GITHUB_PATH" - name: Build Windows release binary and notices without signing credentials - run: python3 ../../scripts/ci/agent_measure.py --phase windows-release-build --target target -- bash ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" + run: | + # Native Python otherwise finds System32's WSL bash launcher first. + git_bash="$(cygpath -w "$BASH")" + python3 ../../scripts/ci/agent_measure.py --phase windows-release-build --target target -- "$git_bash" ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE" - name: Inspect immutable Windows profile and source run: python3 scripts/windows-release.py prepare "$MAPLE_RELEASE_PROFILE" - name: Upload prebuilt Windows binary and notices diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index b024dad39..f2450d58f 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -417,7 +417,14 @@ def test_windows_preview_and_native_verifier_have_no_signing_credentials(self): release = next(step["run"] for step in steps if step.get("name") == "Build Windows release binary and notices without signing credentials") # Native Windows CreateProcess cannot launch a Unix shebang script. - self.assertIn('--target target -- bash ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE"', release) + self.assertIn('git_bash="$(cygpath -w "$BASH")"', release) + self.assertIn('--target target -- "$git_bash" ./scripts/build-release.sh "$MAPLE_RELEASE_PROFILE"', release) + self.assertNotIn('-- bash ', release) + shell_probe = next(i for i, step in enumerate(steps) + if step.get("name") == "Smoke native Git Bash process launch") + graph = next(i for i, step in enumerate(steps) + if step.get("name") == "Verify the canonical SDK and proxy dependency graph") + self.assertLess(shell_probe, graph) upload = next(step["with"] for step in steps if step.get("uses", "").startswith("actions/upload-artifact@")) self.assertEqual(upload["path"].splitlines(), [ From c1b8351af3e92806942cb56b9d3c0efd4d4fa920 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:13:16 +0000 Subject: [PATCH 5/7] fix(agent): recognize native Windows ICU and WER imports --- apps/maple-agent/docs/desktop-builds.md | 5 ++++- apps/maple-agent/scripts/test_windows_release.py | 5 ++++- apps/maple-agent/scripts/windows-installer.nsi | 10 ++++++++-- apps/maple-agent/scripts/windows-release.py | 6 +++--- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 054bc4944..261735260 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -18,7 +18,10 @@ with a bundled runtime and a normal application directory. CI checks each downloaded bundle's command-line startup and detached host lifecycle in an Ubuntu 24.04 container with no Nix installation and no network access. Each master run completes independently; newer PR updates cancel older -previews. Windows x64 packages are per-user NSIS installers. Trusted master +previews. Windows x64 packages are per-user NSIS installers requiring Windows 10 +version 1703 (build 15063) or later. This baseline supplies the OS `icuuc.dll` +imported by GPUI; see Microsoft's [Windows ICU version history](https://learn.microsoft.com/en-us/windows/win32/intl/international-components-for-unicode--icu-). +Trusted master packages use the existing Azure publisher to Authenticode-sign the application, embedded uninstaller and final installer. PRs produce unsigned Dev previews. diff --git a/apps/maple-agent/scripts/test_windows_release.py b/apps/maple-agent/scripts/test_windows_release.py index 5d35079a1..83bc6c742 100644 --- a/apps/maple-agent/scripts/test_windows_release.py +++ b/apps/maple-agent/scripts/test_windows_release.py @@ -70,11 +70,14 @@ def test_unknown_installer_payload_cannot_be_blessed_by_new_checksums(self): class RuntimeClosureTests(unittest.TestCase): def test_os_components_and_api_sets_are_allowed_but_compiler_runtime_is_not(self): + # ICU is an OS component from Win10 1703; WER is an OS component from + # Vista. Both appeared in the actual optimized Agent executable. allowed = ["kernel32.dll", "dwrite.dll", "d3d11.dll", "uiautomationcore.dll", + "icuuc.dll", "wer.dll", "api-ms-win-core-synch-l1-2-0.dll"] with patch.object(release.pe, "inspect_pe", return_value={"imports": allowed}): release.validate_imports(Path("unused.exe")) - for unexpected in ("vcruntime140.dll", "msvcp140.dll", "libcrypto.dll", "onnxruntime.dll", "api-ms-win-../../evil.dll", "unknown.dll"): + for unexpected in ("vcruntime140.dll", "msvcp140.dll", "libcrypto.dll", "onnxruntime.dll", "icuuc999.dll", "api-ms-win-../../evil.dll", "unknown.dll"): with self.subTest(unexpected=unexpected), patch.object(release.pe, "inspect_pe", return_value={"imports": allowed + [unexpected]}): with self.assertRaisesRegex(ValueError, "non-OS DLL"): release.validate_imports(Path("unused.exe")) diff --git a/apps/maple-agent/scripts/windows-installer.nsi b/apps/maple-agent/scripts/windows-installer.nsi index 045b777c9..e291256d3 100644 --- a/apps/maple-agent/scripts/windows-installer.nsi +++ b/apps/maple-agent/scripts/windows-installer.nsi @@ -36,9 +36,15 @@ Function .onInit StrCmp $0 "AMD64" 0 unsupported GetDLLVersion "$SYSDIR\kernel32.dll" $0 $1 IntOp $0 $0 >> 16 - IntCmp $0 10 supported unsupported supported + IntCmp $0 10 check_build unsupported supported +check_build: + ; GPUI imports the OS ICU library, first shipped in Windows 10 version 1703. + ; GetDLLVersion's second DWORD contains the build in its high 16 bits. + IntOp $1 $1 >> 16 + IntOp $1 $1 & 0xFFFF + IntCmp $1 15063 supported unsupported supported unsupported: - MessageBox MB_OK|MB_ICONSTOP "${DISPLAY_NAME} requires Windows 10 or later on an x64 computer." /SD IDOK + MessageBox MB_OK|MB_ICONSTOP "${DISPLAY_NAME} requires Windows 10 version 1703 (build 15063) or later on an x64 computer." /SD IDOK SetErrorLevel 1 Abort supported: diff --git a/apps/maple-agent/scripts/windows-release.py b/apps/maple-agent/scripts/windows-release.py index fcdd00acd..fa31b06cd 100644 --- a/apps/maple-agent/scripts/windows-release.py +++ b/apps/maple-agent/scripts/windows-release.py @@ -30,20 +30,20 @@ NSIS_VERSION = "3.12" NSIS_URL = "https://downloads.sourceforge.net/project/nsis/NSIS%203/3.12/nsis-3.12.zip" NSIS_SHA256 = "56581f90db321581c5381193d796fffcf2d24b2f8fed2160a6c6a3baa67f2c4f" -# Windows 10+ OS components only. No compiler-installation, VC redistributable, +# Windows 10 version 1703+ OS components only. No compiler-installation, VC redistributable, # crypto-provider or application DLL may be supplied by the build runner's PATH. SYSTEM_IMPORTS = frozenset(""" advapi32.dll avrt.dll bcrypt.dll bcryptprimitives.dll cabinet.dll cfgmgr32.dll combase.dll comctl32.dll comdlg32.dll credui.dll crypt32.dll cryptbase.dll cryptnet.dll cryptsp.dll d2d1.dll d3d11.dll d3d12.dll d3dcompiler_47.dll dbghelp.dll dcomp.dll dhcpcsvc.dll dnsapi.dll dwmapi.dll dwrite.dll dxgi.dll - gdi32.dll glu32.dll hid.dll imm32.dll iphlpapi.dll kernel32.dll kernelbase.dll + gdi32.dll glu32.dll hid.dll icuuc.dll imm32.dll iphlpapi.dll kernel32.dll kernelbase.dll mf.dll mfplat.dll mfreadwrite.dll mmdevapi.dll msasn1.dll msimg32.dll msctf.dll msvcrt.dll mswsock.dll ncrypt.dll netapi32.dll normaliz.dll ntdll.dll ole32.dll oleacc.dll oleaut32.dll opengl32.dll powrprof.dll propsys.dll psapi.dll rasapi32.dll rpcrt4.dll secur32.dll setupapi.dll shcore.dll shell32.dll shlwapi.dll synchronization.dll ucrtbase.dll uiautomationcore.dll urlmon.dll user32.dll userenv.dll usp10.dll - uxtheme.dll version.dll wevtapi.dll windowscodecs.dll winhttp.dll wininet.dll + uxtheme.dll version.dll wer.dll wevtapi.dll windowscodecs.dll winhttp.dll wininet.dll winmm.dll winspool.drv wintrust.dll winusb.dll wldap32.dll ws2_32.dll wtsapi32.dll """.split()) NOTICE_FILES = ( From fc15d4a4942e4a77ca9b7bb9d226c21c2b8ac200 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:38:39 +0000 Subject: [PATCH 6/7] fix(agent): isolate Windows verifier from runner Azure CLI paths --- .github/workflows/agent-desktop-build.yml | 3 +++ scripts/ci/test_agent_workflows.py | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index dd3de25f9..b332843aa 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -661,6 +661,9 @@ jobs: GH_TOKEN: ${{ github.token }} - name: Verify downloaded Windows package and install lifecycle run: | + # The stock image exports these Azure CLI tooling/cache paths. + # Keep other Azure/signing variables visible to the fail-closed guard. + unset AZURE_CONFIG_DIR AZURE_DEVOPS_CACHE_DIR AZURE_EXTENSION_DIR args=() if [ "$GITHUB_EVENT_NAME" = pull_request ]; then args=(--unsigned); fi python3 scripts/windows-release.py verify "$MAPLE_RELEASE_PROFILE" ../../artifacts "${args[@]}" --smoke diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 890e2ed4a..fc3d608c3 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -7,6 +7,7 @@ import re import shutil import subprocess +import sys import tempfile import unittest @@ -560,6 +561,28 @@ def test_windows_preview_and_native_verifier_have_no_signing_credentials(self): self.assertNotIn("environment", jobs[name]) self.assertFalse(any("secrets." in value for value in strings(jobs[name]))) + def test_windows_verifier_clears_runner_paths_without_hiding_signing_configuration(self): + steps = workflow(self.WORKFLOW)["jobs"]["verify-windows"]["steps"] + smoke = next(step["run"] for step in steps if "windows-release.py verify" in step.get("run", "")) + paths = {"AZURE_CONFIG_DIR", "AZURE_DEVOPS_CACHE_DIR", "AZURE_EXTENSION_DIR"} + credentials = {"AZURE_CLIENT_ID", "AZURE_FUTURE_TOKEN", "MAPLE_WINDOWS_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME"} + with tempfile.TemporaryDirectory() as temporary: + output = Path(temporary) / "environment-names.json" + # Execute the real workflow script with a harmless Python probe in + # place of the installer verifier. Record names, never values. + probe = '''python3() { + "$PROBE_PYTHON" -c 'import json, os; print(json.dumps(sorted(os.environ)))' > "$PROBE_OUTPUT" + } + ''' + subprocess.run(["bash", "-euo", "pipefail", "-c", probe + smoke], check=True, + env={**os.environ, **dict.fromkeys(paths | credentials, "fixture"), + "GITHUB_EVENT_NAME": "pull_request", "MAPLE_RELEASE_PROFILE": "dev", + "PROBE_PYTHON": sys.executable, "PROBE_OUTPUT": str(output)}, + capture_output=True) + remaining = set(json.loads(output.read_text())) + self.assertFalse(paths & remaining) + self.assertTrue(credentials <= remaining) + def test_only_pr_updates_can_replace_an_in_progress_or_pending_build(self): concurrency = workflow(self.WORKFLOW)["concurrency"] self.assertEqual(concurrency, { From a1fb120dfeb2b800f57fcfe261deebedead96229 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:43:24 +0000 Subject: [PATCH 7/7] fix(agent): start Windows hosts within restrictive supervisor jobs --- .github/workflows/agent-desktop-build.yml | 2 + apps/maple-agent/app/src/host.rs | 13 +- .../maple-agent/app/src/host/windows_spawn.rs | 26 +++ apps/maple-agent/docs/desktop-builds.md | 3 + apps/maple-agent/justfile | 1 + .../scripts/test_windows_host_spawn.py | 212 ++++++++++++++++++ flake.nix | 1 + scripts/ci/test_agent_workflows.py | 3 + 8 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 apps/maple-agent/app/src/host/windows_spawn.rs create mode 100644 apps/maple-agent/scripts/test_windows_host_spawn.py diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index b332843aa..ddc9244d0 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -397,6 +397,8 @@ jobs: "GPUI_FXC_PATH=$fxc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - name: Exercise Windows metadata parser and native linker retention run: python3 scripts/test_windows_build_info.py + - name: Exercise Windows host launch in a restrictive job + run: python3 scripts/test_windows_host_spawn.py - name: Smoke native Git Bash process launch run: | git_bash="$(cygpath -w "$BASH")" diff --git a/apps/maple-agent/app/src/host.rs b/apps/maple-agent/app/src/host.rs index 0421f334f..efda2e957 100644 --- a/apps/maple-agent/app/src/host.rs +++ b/apps/maple-agent/app/src/host.rs @@ -16,6 +16,8 @@ mod send_receipt_tests; pub(crate) mod subscription; #[cfg(test)] mod subscription_tests; +#[cfg(windows)] +mod windows_spawn; use crate::backend::{self, LocalBackend}; use maple_agent::agent::AgentServiceEvent; @@ -600,13 +602,10 @@ fn spawn_host() -> Result<(), String> { } } #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - command.creation_flags(0x00000008 | 0x00000200 | 0x01000000); // detached, new group, break away from client job - } - let mut child = command - .spawn() - .map_err(|e| format!("Cannot start Maple host: {e}"))?; + let child = windows_spawn::spawn(&mut command); + #[cfg(not(windows))] + let child = command.spawn(); + let mut child = child.map_err(|e| format!("Cannot start Maple host: {e}"))?; // Reap on Unix while the parent remains alive; the host is not kill-on-drop. std::thread::spawn(move || { let _ = child.wait(); diff --git a/apps/maple-agent/app/src/host/windows_spawn.rs b/apps/maple-agent/app/src/host/windows_spawn.rs new file mode 100644 index 000000000..1c384ebeb --- /dev/null +++ b/apps/maple-agent/app/src/host/windows_spawn.rs @@ -0,0 +1,26 @@ +//! Detach a host without overriding an enclosing Windows job's policy. + +use std::io; +use std::os::windows::process::CommandExt; +use std::process::{Child, Command}; + +const DETACHED_PROCESS: u32 = 0x00000008; +const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; +const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x01000000; +const ERROR_ACCESS_DENIED: i32 = 5; + +pub(super) fn spawn(command: &mut Command) -> io::Result { + let detached = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP; + match command + .creation_flags(detached | CREATE_BREAKAWAY_FROM_JOB) + .spawn() + { + Err(error) if error.raw_os_error() == Some(ERROR_ACCESS_DENIED) => { + // A job can refuse breakaway even when ordinary child creation is + // allowed. Keep that job's restrictions and lifetime on the host; + // retrying without breakaway never relaxes the enclosing policy. + command.creation_flags(detached).spawn() + } + result => result, + } +} diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 261735260..16f622898 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -64,6 +64,9 @@ it does not need another Azure app registration or certificate profile. Native verifiers check installed payload identity, system-only DLL imports, Authenticode signatures for master packages, CLI startup and isolated install/uninstall. These checks do not establish interactive rendering, browser sign-in or tray behavior. +The host detaches from the client's console and process group. If an enclosing +Windows job forbids breakaway, the host retains that job's limits and lifetime; +terminating the enclosing job also terminates its host. Built-in Windows computer-use remains tracked separately in [issue #1295](https://github.com/MaplePrivacyLabs/Maple/issues/1295). diff --git a/apps/maple-agent/justfile b/apps/maple-agent/justfile index b2d8cc515..75e17e17b 100644 --- a/apps/maple-agent/justfile +++ b/apps/maple-agent/justfile @@ -103,6 +103,7 @@ packaging-test: python3 ./scripts/test_macos_release_dylibs.py python3 ./scripts/test_macos_build_info.py python3 ./scripts/test_windows_build_info.py + python3 ./scripts/test_windows_host_spawn.py python3 ./scripts/test_windows_release.py python3 ./scripts/test_smoke_macos_release.py python3 ./scripts/test_smoke_linux_bundle_host.py diff --git a/apps/maple-agent/scripts/test_windows_host_spawn.py b/apps/maple-agent/scripts/test_windows_host_spawn.py new file mode 100644 index 000000000..ddf13951b --- /dev/null +++ b/apps/maple-agent/scripts/test_windows_host_spawn.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Exercise the actual host spawn helper inside a restrictive native job.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +SCRIPTS = Path(__file__).resolve().parent +HELPER = SCRIPTS.parent / "app/src/host/windows_spawn.rs" +FIXTURE = r''' +#[path = HELPER_PATH] +mod windows_spawn; + +use std::ffi::{OsStr, c_void}; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::process::CommandExt; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +type Handle = *mut c_void; +const JOB_OBJECT_ASSIGN_PROCESS: u32 = 0x0001; +const JOB_OBJECT_QUERY: u32 = 0x0004; +const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x00002000; +const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION: i32 = 9; +const DETACHED_BREAKAWAY: u32 = 0x00000008 | 0x00000200 | 0x01000000; + +#[repr(C)] +#[derive(Default)] +struct BasicLimitInformation { + per_process_user_time_limit: i64, + per_job_user_time_limit: i64, + limit_flags: u32, + minimum_working_set_size: usize, + maximum_working_set_size: usize, + active_process_limit: u32, + affinity: usize, + priority_class: u32, + scheduling_class: u32, +} + +#[repr(C)] +#[derive(Default)] +struct IoCounters { + read_operation_count: u64, + write_operation_count: u64, + other_operation_count: u64, + read_transfer_count: u64, + write_transfer_count: u64, + other_transfer_count: u64, +} + +#[repr(C)] +#[derive(Default)] +struct ExtendedLimitInformation { + basic_limit_information: BasicLimitInformation, + io_info: IoCounters, + process_memory_limit: usize, + job_memory_limit: usize, + peak_process_memory_used: usize, + peak_job_memory_used: usize, +} + +#[link(name = "kernel32")] +unsafe extern "system" { + fn CreateJobObjectW(attributes: *const c_void, name: *const u16) -> Handle; + fn OpenJobObjectW(access: u32, inherit: i32, name: *const u16) -> Handle; + fn SetInformationJobObject(job: Handle, class: i32, info: *const c_void, size: u32) -> i32; + fn AssignProcessToJobObject(job: Handle, process: Handle) -> i32; + fn IsProcessInJob(process: Handle, job: Handle, result: *mut i32) -> i32; + fn GetCurrentProcess() -> Handle; + fn CloseHandle(handle: Handle) -> i32; + fn GetLastError() -> u32; +} + +struct Job(Handle); + +impl Drop for Job { + fn drop(&mut self) { + // SAFETY: Job owns one real handle returned by Create/OpenJobObjectW. + assert_ne!(unsafe { CloseHandle(self.0) }, 0); + } +} + +fn wide(value: &OsStr) -> Vec { + value.encode_wide().chain(Some(0)).collect() +} + +fn open_job(name: &OsStr, access: u32) -> Job { + // SAFETY: name is terminated UTF-16; the returned handle is checked. + let handle = unsafe { OpenJobObjectW(access, 0, wide(name).as_ptr()) }; + assert!(!handle.is_null(), "open fixture job: {}", io::Error::last_os_error()); + Job(handle) +} + +fn assert_membership(job: &Job) { + let mut member = 0; + // SAFETY: current-process pseudo-handle, live job handle, writable result. + assert_ne!(unsafe { IsProcessInJob(GetCurrentProcess(), job.0, &mut member) }, 0, + "query fixture job: {}", io::Error::last_os_error()); + assert_eq!(member, 1, "helper child escaped the restrictive fixture job"); +} + +fn probe_command(job: &OsStr) -> Command { + let mut command = Command::new(std::env::current_exe().unwrap()); + command.arg("probe").arg(job) + .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + command +} + +fn restricted(job_name: &OsStr) { + { + let job = open_job(job_name, JOB_OBJECT_ASSIGN_PROCESS | JOB_OBJECT_QUERY); + // SAFETY: both handles are live. Only this isolated fixture process is + // assigned; existing runner jobs and their restrictions are retained. + assert_ne!(unsafe { AssignProcessToJobObject(job.0, GetCurrentProcess()) }, 0, + "assign restrictive fixture job: {}", io::Error::last_os_error()); + assert_membership(&job); + // Parent keeps its own handle alive until this worker exits. Releasing + // this handle cannot kill the worker or leak the job into probe children. + } + match probe_command(job_name).creation_flags(DETACHED_BREAKAWAY).spawn() { + Err(error) => assert_eq!(error.raw_os_error(), Some(5), "direct breakaway error: {error}"), + Ok(mut child) => { + let _ = child.kill(); + let _ = child.wait(); + panic!("the restrictive fixture job unexpectedly permitted breakaway"); + } + } + let mut child = windows_spawn::spawn(&mut probe_command(job_name)) + .expect("host helper must retry without breakaway"); + assert!(child.wait().unwrap().success(), "helper child did not retain exact job membership"); + + let missing = std::env::current_exe().unwrap().with_file_name("missing-fixture-executable.exe"); + let error = windows_spawn::spawn(&mut Command::new(missing)) + .expect_err("a missing executable must not become a successful host start"); + assert_eq!(error.raw_os_error(), Some(2), "unrelated spawn error must propagate"); + println!("Verified direct breakaway denial, inherited-job host startup and unrelated-error propagation"); +} + +fn main() { + let arguments: Vec<_> = std::env::args_os().skip(1).collect(); + match arguments.first().and_then(|value| value.to_str()) { + Some("probe") => { + let job = open_job(&arguments[1], JOB_OBJECT_QUERY); + assert_membership(&job); + } + Some("restricted") => restricted(&arguments[1]), + None => { + let name = format!("Local\\MapleHostSpawn-{}-{}", std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()); + // SAFETY: no custom security descriptor; name is terminated UTF-16. + let wide_name = wide(OsStr::new(&name)); + let handle = unsafe { CreateJobObjectW(std::ptr::null(), wide_name.as_ptr()) }; + let creation_error = unsafe { GetLastError() }; + assert!(!handle.is_null(), "create fixture job: {}", io::Error::last_os_error()); + assert_ne!(creation_error, 183, "fixture job must be newly created"); + let job = Job(handle); + let mut limits = ExtendedLimitInformation::default(); + // No BREAKAWAY_OK or SILENT_BREAKAWAY_OK. The parent's handle also + // guarantees cleanup of the worker and children on an outer timeout. + limits.basic_limit_information.limit_flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: the C-layout struct is initialized and its size is exact. + assert_ne!(unsafe { SetInformationJobObject(job.0, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + &limits as *const _ as *const c_void, std::mem::size_of_val(&limits) as u32) }, 0, + "set restrictive fixture limits: {}", io::Error::last_os_error()); + let status = Command::new(std::env::current_exe().unwrap()) + .arg("restricted").arg(&name).status().unwrap(); + assert!(status.success(), "restrictive job regression worker failed: {status}"); + } + Some(other) => panic!("unknown fixture mode: {other}"), + } +} +''' + + +class WindowsHostSpawnTests(unittest.TestCase): + @unittest.skipUnless(sys.platform == "win32" and shutil.which("rustc"), + "restrictive job fixture requires native Windows and Rust") + def test_host_starts_inside_a_job_that_denies_breakaway(self): + with tempfile.TemporaryDirectory(prefix="maple-host-job-") as temporary: + directory = Path(temporary) + source = directory / "host_job.rs" + executable = directory / "host-job.exe" + # Rust's regular string syntax accepts JSON's escaped Windows path. + source.write_text(FIXTURE.replace("HELPER_PATH", json.dumps(str(HELPER), ensure_ascii=False)), encoding="utf-8") + command = ["rustc", "--edition=2024", "-D", "warnings", "-C", "opt-level=1"] + linker = os.environ.get("CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER") + if os.environ.get("GITHUB_ACTIONS") == "true": + self.assertIsNotNone(linker, "native fixture requires CI's verified MSVC linker") + if linker: + selected = Path(linker) + self.assertTrue(selected.is_absolute() and selected.is_file() + and selected.name.lower() == "link.exe") + command.extend(["-C", f"linker={selected}"]) + compiled = subprocess.run([*command, str(source), "-o", str(executable)], + capture_output=True, text=True, timeout=120) + self.assertEqual(compiled.returncode, 0, compiled.stderr[-8192:]) + result = subprocess.run([str(executable)], capture_output=True, + text=True, timeout=45) + self.assertEqual(result.returncode, 0, (result.stdout + result.stderr)[-8192:]) + self.assertIn("Verified direct breakaway denial", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/flake.nix b/flake.nix index 933c070c4..c0aaf4a05 100644 --- a/flake.nix +++ b/flake.nix @@ -832,6 +832,7 @@ python3 apps/maple-agent/scripts/test_macos_release_dylibs.py python3 apps/maple-agent/scripts/test_macos_build_info.py python3 apps/maple-agent/scripts/test_windows_build_info.py + python3 apps/maple-agent/scripts/test_windows_host_spawn.py python3 apps/maple-agent/scripts/test_windows_release.py python3 apps/maple-agent/scripts/test_smoke_macos_release.py python3 apps/maple-agent/scripts/test_smoke_linux_bundle_host.py diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index fc3d608c3..0d0b068ff 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -532,6 +532,9 @@ def test_windows_preview_and_native_verifier_have_no_signing_credentials(self): graph = next(i for i, step in enumerate(steps) if step.get("name") == "Verify the canonical SDK and proxy dependency graph") self.assertLess(shell_probe, graph) + host_probe = next(i for i, step in enumerate(steps) + if step.get("run") == "python3 scripts/test_windows_host_spawn.py") + self.assertLess(host_probe, graph) upload = next(step["with"] for step in steps if step.get("uses", "").startswith("actions/upload-artifact@")) self.assertEqual(upload["path"].splitlines(), [